diff --git a/.claude/worktrees/agent-aa2f0435/.dockerignore b/.claude/worktrees/agent-aa2f0435/.dockerignore deleted file mode 100644 index 6975e219..00000000 --- a/.claude/worktrees/agent-aa2f0435/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -target/ -ui/node_modules/ -ui/dist/ -TestData/ -.git/ -*.swp -*.swo -.env diff --git a/.claude/worktrees/agent-aa2f0435/.github/workflows/bootstrap-deploy.yml b/.claude/worktrees/agent-aa2f0435/.github/workflows/bootstrap-deploy.yml deleted file mode 100644 index f50e3db5..00000000 --- a/.claude/worktrees/agent-aa2f0435/.github/workflows/bootstrap-deploy.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: Bootstrap CI & Deploy - -on: - push: - branches: [main] - paths: - - 'crates/stackarr-bootstrap/**' - - 'docker/Dockerfile.bootstrap' - - 'docker/root-bootstrap/**' - - 'docker/docker-compose.bootstrap.yml' - - '.github/workflows/bootstrap-deploy.yml' - workflow_dispatch: - -concurrency: - group: bootstrap-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: [self-hosted, node-b] - name: Compile Bootstrap - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev cmake gcc-12 g++-12 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - prefix-key: v1-bootstrap - - - name: Compile check - run: cargo check -p stackarr-bootstrap - env: - CC: gcc-12 - CXX: g++-12 - - build-and-publish: - runs-on: [self-hosted, node-b] - needs: check - name: Build & Push Docker Image - - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: docker-container - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/ausagentsmith-org/stackarr-bootstrap - tags: | - type=ref,event=branch - type=sha,prefix=,format=long - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile.bootstrap - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - container-test: - runs-on: [self-hosted, node-b] - needs: build-and-publish - name: Docker Smoke Test - - permissions: - packages: read - - steps: - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pull and smoke test - run: | - IMAGE="ghcr.io/ausagentsmith-org/stackarr-bootstrap:${{ github.sha }}" - echo "==> Pulling ${IMAGE}..." - docker pull "${IMAGE}" - echo "==> Checking binary exists..." - docker run --rm --entrypoint /usr/local/bin/stackarr-bootstrap "${IMAGE}" --help - - deploy: - runs-on: [self-hosted, node-b] - needs: [build-and-publish, container-test] - if: github.ref == 'refs/heads/main' - name: Deploy to Vultr - - permissions: - contents: read - packages: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up SSH - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq openssh-client > /dev/null - mkdir -p ~/.ssh - echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - - - name: Deploy Bootstrap - env: - DEPLOY_DIR: /opt/stackarr-bootstrap - run: | - set -e - SSH="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o ConnectTimeout=10 root@100.92.4.57" - - echo "==> Pulling latest image on Vultr..." - ${SSH} "docker pull ghcr.io/ausagentsmith-org/stackarr-bootstrap:latest" - - echo "==> Deploying compose file..." - ${SSH} "mkdir -p ${DEPLOY_DIR}" - cat docker/docker-compose.bootstrap.yml | ${SSH} "cat > ${DEPLOY_DIR}/docker-compose.yml" - - echo "==> Restarting services..." - ${SSH} "cd ${DEPLOY_DIR} && docker compose up -d --remove-orphans" - - echo "==> Waiting for health check..." - for i in $(seq 1 12); do - if ${SSH} "curl -sf http://localhost:7890/health" > /dev/null 2>&1; then - echo "Health check passed!" - ${SSH} "cd ${DEPLOY_DIR} && docker compose ps && docker image prune -f" - exit 0 - fi - echo "Attempt $i/12 - waiting 10s..." - sleep 10 - done - echo "Health check failed" - ${SSH} "cd ${DEPLOY_DIR} && docker compose logs --tail=30 bootstrap" - exit 1 diff --git a/.claude/worktrees/agent-aa2f0435/.github/workflows/client-build.yml b/.claude/worktrees/agent-aa2f0435/.github/workflows/client-build.yml deleted file mode 100644 index b111c42a..00000000 --- a/.claude/worktrees/agent-aa2f0435/.github/workflows/client-build.yml +++ /dev/null @@ -1,221 +0,0 @@ -name: Build Client - -on: - push: - branches: [main] - paths: - - 'client/**' - pull_request: - paths: - - 'client/**' - workflow_dispatch: - -concurrency: - group: client-${{ github.ref }} - cancel-in-progress: true - -jobs: - # Browser build (static web app) - build-web: - runs-on: [self-hosted, node-b] - name: Build Web Client - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Build web client - run: | - cd client - npm ci - npm run build - - - name: Upload web artifact - uses: actions/upload-artifact@v4 - with: - name: stackarr-player-web - path: client/dist/ - retention-days: 30 - - # Windows desktop build via Tauri - build-windows: - runs-on: windows-latest - name: Build Windows Client - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - prefix-key: v1-tauri-windows - workspaces: client/src-tauri - - - name: Install frontend dependencies - run: | - cd client - npm ci - - - name: Build Tauri app - run: | - cd client - npx tauri build - env: - TAURI_SIGNING_PRIVATE_KEY: "" - - - name: Upload Windows installer - uses: actions/upload-artifact@v4 - with: - name: stackarr-player-windows - path: | - client/src-tauri/target/release/bundle/msi/*.msi - client/src-tauri/target/release/bundle/nsis/*.exe - retention-days: 30 - - # Android build via Tauri - build-android: - runs-on: ubuntu-latest - name: Build Android Client - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android,i686-linux-android - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 17 - - - name: Set up Android SDK - uses: android-actions/setup-android@v3 - - - name: Install Android NDK - run: | - sdkmanager --install "ndk;27.0.12077973" - echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> $GITHUB_ENV - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - prefix-key: v1-tauri-android - workspaces: client/src-tauri - - - name: Install frontend dependencies - run: | - cd client - npm ci - - - name: Initialize Tauri Android - run: | - cd client - npx tauri android init - - - name: Decode signing keystore - run: | - echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > ${{ runner.temp }}/release.keystore - - - name: Build Android APK - run: | - cd client - npx tauri android build --apk - env: - NDK_HOME: ${{ env.NDK_HOME }} - - - name: Sign APK with release keystore - run: | - UNSIGNED=$(find client/src-tauri/gen/android/app/build/outputs/apk -name '*.apk' | head -1) - # Align the APK - "${ANDROID_HOME}/build-tools/$(ls "${ANDROID_HOME}/build-tools/" | sort -V | tail -1)/zipalign" -v -p 4 \ - "$UNSIGNED" "${{ runner.temp }}/aligned.apk" - # Sign with apksigner - "${ANDROID_HOME}/build-tools/$(ls "${ANDROID_HOME}/build-tools/" | sort -V | tail -1)/apksigner" sign \ - --ks ${{ runner.temp }}/release.keystore \ - --ks-key-alias ${{ secrets.ANDROID_KEY_ALIAS }} \ - --ks-pass pass:${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ - --key-pass pass:${{ secrets.ANDROID_KEY_PASSWORD }} \ - --out "${{ runner.temp }}/stackarr-player.apk" \ - "${{ runner.temp }}/aligned.apk" - # Verify - "${ANDROID_HOME}/build-tools/$(ls "${ANDROID_HOME}/build-tools/" | sort -V | tail -1)/apksigner" verify \ - "${{ runner.temp }}/stackarr-player.apk" - - - name: Upload Android APK - uses: actions/upload-artifact@v4 - with: - name: stackarr-player-android - path: ${{ runner.temp }}/stackarr-player.apk - retention-days: 30 - - # Create/update rolling "latest" release with all platform artifacts - release: - runs-on: ubuntu-latest - name: Publish Release - needs: [build-web, build-windows, build-android] - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - - permissions: - contents: write - - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts/ - - - name: Package web client - run: | - cd artifacts/stackarr-player-web - zip -r ../../stackarr-player-web.zip . - - - name: Prepare release files - run: | - mkdir -p release - cp stackarr-player-web.zip release/ - cp artifacts/stackarr-player-windows/*.msi release/ 2>/dev/null || true - cp artifacts/stackarr-player-windows/*.exe release/ 2>/dev/null || true - find artifacts/stackarr-player-android -name "*.apk" -exec cp {} release/ \; 2>/dev/null || true - ls -la release/ - - - name: Create or update release - uses: softprops/action-gh-release@v2 - with: - tag_name: player-latest - name: "StackArr Player (latest)" - body: | - Rolling release of the StackArr Player client, built from the latest `main` branch. - - **Downloads:** - - **Windows** — `.msi` (installer) or `.exe` (NSIS setup) - - **Android** — `.apk` (sideload) - - **Web** — `.zip` (deploy as static site) - - Built from commit ${{ github.sha }} - prerelease: true - make_latest: false - files: release/* - generate_release_notes: false diff --git a/.claude/worktrees/agent-aa2f0435/.github/workflows/docker-deploy.yml b/.claude/worktrees/agent-aa2f0435/.github/workflows/docker-deploy.yml deleted file mode 100644 index c42eb355..00000000 --- a/.claude/worktrees/agent-aa2f0435/.github/workflows/docker-deploy.yml +++ /dev/null @@ -1,194 +0,0 @@ -name: CI & Deploy - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - runs-on: [self-hosted, node-b] - name: Compile & Test - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential pkg-config libssl-dev cmake gcc-12 g++-12 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - with: - prefix-key: v1-stackarr - - - name: Compile check - run: cargo check --workspace - env: - CC: gcc-12 - CXX: g++-12 - - - name: Run tests - run: cargo test --workspace --lib - env: - CC: gcc-12 - CXX: g++-12 - - - name: UI type check - run: | - cd ui - npm ci - npx tsc --noEmit -p tsconfig.app.json - - - name: Client type check - run: | - cd client - npm ci - npx tsc --noEmit -p tsconfig.app.json - - build-and-publish: - runs-on: [self-hosted, node-b] - needs: check - if: github.event_name != 'pull_request' - name: Build & Push Docker Image - - permissions: - contents: read - packages: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver: docker-container - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/ausagentsmith-org/stackarr - tags: | - type=ref,event=branch - type=sha,prefix=,format=long - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: docker/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - container-test: - runs-on: [self-hosted, node-b] - needs: build-and-publish - name: Docker Smoke Test - - permissions: - packages: read - - steps: - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Pull and smoke test - run: | - IMAGE="ghcr.io/ausagentsmith-org/stackarr:${{ github.sha }}" - echo "==> Pulling ${IMAGE}..." - docker pull "${IMAGE}" - echo "==> Checking binary exists..." - docker run --rm --entrypoint /usr/local/bin/stackarr "${IMAGE}" --help - - deploy: - runs-on: [self-hosted, node-b] - needs: [build-and-publish, container-test] - if: github.ref == 'refs/heads/main' - name: Deploy to Node B - - permissions: - contents: read - packages: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up SSH - run: | - sudo apt-get update -qq && sudo apt-get install -y -qq openssh-client > /dev/null - mkdir -p ~/.ssh - echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - - - name: Deploy StackArr - env: - DEPLOY_DIR: /mnt/2tnvme/docker/volumes/stackarr - run: | - set -e - SSH="ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no -o ConnectTimeout=10 sprooty@192.168.0.30" - - echo "==> Pulling latest image..." - docker pull ghcr.io/ausagentsmith-org/stackarr:latest - - echo "==> Deploying compose file..." - ${SSH} "mkdir -p ${DEPLOY_DIR}" - cat docker/docker-compose.prod.yml | ${SSH} "cat > ${DEPLOY_DIR}/docker-compose.yml" - - echo "==> Restarting services..." - ${SSH} "cd ${DEPLOY_DIR} && docker compose up -d --remove-orphans" - - echo "==> Waiting for health check..." - for i in $(seq 1 12); do - if ${SSH} "curl -sf http://localhost:9111/health" > /dev/null 2>&1; then - echo "Health check passed!" - ${SSH} "cd ${DEPLOY_DIR} && docker compose ps && docker image prune -f" - exit 0 - fi - echo "Attempt $i/12 - waiting 10s..." - sleep 10 - done - echo "Health check failed" - ${SSH} "cd ${DEPLOY_DIR} && docker compose logs --tail=30 stackarr" - exit 1 diff --git a/.claude/worktrees/agent-aa2f0435/.gitignore b/.claude/worktrees/agent-aa2f0435/.gitignore deleted file mode 100644 index c8b10298..00000000 --- a/.claude/worktrees/agent-aa2f0435/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -/target -*.swp -*.swo -.env -config.toml -/ui/node_modules -/ui/dist -TestData/ -seerr/ -seerr/ -seerintegration.md -test-fixtures/*.db diff --git a/.claude/worktrees/agent-aa2f0435/CLAUDE.md b/.claude/worktrees/agent-aa2f0435/CLAUDE.md deleted file mode 100644 index 15e05c4e..00000000 --- a/.claude/worktrees/agent-aa2f0435/CLAUDE.md +++ /dev/null @@ -1,128 +0,0 @@ -# StackArr — CLAUDE.md - -StackArr is a unified media management server written in Rust that replaces Sonarr + Radarr + Prowlarr with a single binary. It manages TV series and movies with embedded torrent (librtbit) and usenet (nzb) download engines, an integrated indexer sidecar (Indexarr), Plex integration, and a React TypeScript frontend. - -## Quick Reference - -| Item | Value | -|------|-------| -| Language | Rust 2024 edition, TypeScript (React 19) | -| Build | `cargo build --release` (backend), `npm run build` in `ui/` (frontend) | -| Test | `cargo test --workspace --lib` | -| Dev DB | `docker compose -f docker/docker-compose.dev.yml up -d` (Postgres on :5433) | -| Dev UI | `cd ui && npm run dev` (Vite on :3000, proxies API to :8989) | -| Run | `cargo run -- --config stackarr.toml` | -| Lint | `cargo clippy --workspace`, `cd ui && npm run lint` | -| Port | 8989 (dev), 9111 (prod/Docker) | -| Database | PostgreSQL 17 (required). **Never use SQLite for application data.** | -| Config | TOML file + env vars (`STACKARR_*`) + CLI flags | -| Deploy | GitHub Actions → GHCR → Node B via SSH | - -## Repository Layout - -``` -├── src/main.rs # Binary entrypoint (CLI, init, server startup) -├── crates/ -│ ├── stackarr-core/ # Config, DB, error types, models, migrations -│ ├── stackarr-web/ # Axum routes, middleware, AppState, SPA serving -│ ├── stackarr-media/ # Series/Movie/Episode CRUD services -│ ├── stackarr-parser/ # Release name → structured metadata parser -│ ├── stackarr-quality/ # Quality profiles, format scoring -│ ├── stackarr-indexer/ # Newznab/Torznab/Cardigann/Indexarr search clients -│ ├── stackarr-cardigann/ # Prowlarr-compatible YAML indexer definition engine -│ ├── stackarr-cardigann-parity/ # QA binary: Prowlarr parity testing -│ ├── stackarr-download/ # Download client trait + manager -│ ├── stackarr-import/ # Disk scan, file import, rename engine -│ ├── stackarr-scheduler/ # Background task scheduler -│ ├── stackarr-metadata/ # TMDB API client (cached + rate-limited) -│ ├── stackarr-notify/ # Webhook/Discord/Telegram/Slack/Email notifications -│ ├── stackarr-migrate/ # Sonarr/Radarr/Prowlarr SQLite → Postgres migration -│ ├── stackarr-plex/ # Plex API, scanner, watchlist sync -│ ├── stackarr-stream/ # Video streaming (direct play, HLS, ffmpeg transcode) -│ ├── stackarr-bootstrap/ # Standalone discovery node for remote access -│ ├── torrent/ # Vendored librtbit (12 crates, from rustTorrent) -│ └── usenet/ # Vendored nzb engine (5 crates, from rustnzbd) -├── ui/ # React 19 + TypeScript + Tailwind v4 + TanStack Query -├── migrations/ # PostgreSQL migrations (sqlx) -├── docker/ # Dockerfile, compose files, s6 service defs -└── .github/workflows/ # CI/CD pipeline -``` - -## Architecture at a Glance - -See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full system design. - -``` -┌─────────────────────────────────────────────────────────┐ -│ React SPA (ui/) │ -│ TanStack Query → apiFetch() → /api/v1/* │ -└──────────────────────┬──────────────────────────────────┘ - │ HTTP -┌──────────────────────▼──────────────────────────────────┐ -│ Axum Router (stackarr-web) │ -│ Routes: series, movies, queue, calendar, torrent, ... │ -│ State: Arc (DB pool, config, engines) │ -├─────────────────────────────────────────────────────────┤ -│ Service Layer │ -│ SeriesService · MovieService · SearchService │ -│ ImportService · QualityProfileService · PlexScanner │ -├─────────────────────────────────────────────────────────┤ -│ stackarr-core │ stackarr-scheduler │ -│ Database, Config, │ RSS sync, import scan, │ -│ Models, Errors │ metadata refresh, Plex tasks │ -├──────────────────────┼──────────────────────────────────┤ -│ Embedded Engines │ External Integrations │ -│ librtbit (torrent) │ TMDB, Newznab, Cardigann, │ -│ nzb-web (usenet) │ Indexarr, Plex, Discord, │ -│ stackarr-stream │ Webhooks, Telegram, Slack │ -├──────────────────────┴──────────────────────────────────┤ -│ PostgreSQL 17 (sqlx) │ -└─────────────────────────────────────────────────────────┘ -``` - -## Critical Conventions - -### Rust - -- **Edition 2024**, resolver 3. All crates inherit workspace version/edition/lints. -- **Error handling**: `stackarr_core::Error` (thiserror) with variants: `NotFound`, `AlreadyExists`, `Validation`, `Config`, `Database`, `Io`, `Serialization`, `Http`, `DownloadClient`, `Indexer`, `Parse`, `Other(anyhow)`. Propagate with `?`. Use `anyhow::Context` for rich messages. -- **Async everywhere**: Tokio runtime, `async fn`, no blocking I/O on the main runtime. Use `spawn_blocking` for SQLite reads in migration. -- **Database**: sqlx with `query_as::<_, Model>()` and `FromRow` derives. Direct SQL, no ORM. Connection pool via `PgPool` shared in `AppState`. -- **Config hot-reload**: `Arc>` — config can be swapped atomically without restart. -- **API patterns**: Axum extractors (`State`, `Path`, `Query`, `Json`). Return `impl IntoResponse`. Match on `Result` variants for status codes. -- **Logging**: `tracing` crate. Use `tracing::info!`, `tracing::error!` etc. Structured fields preferred. -- **No `unwrap()` in production code.** Use `?` or explicit error handling. - -### TypeScript / React - -- **React 19** with strict TypeScript. TanStack React Query v5 for server state. -- **No Redux / Zustand** — server is source of truth, TanStack Query handles caching (30s staleTime). -- **Styling**: Tailwind CSS v4 utility classes. Dark theme with slate palette. No CSS modules. -- **API client**: `apiFetch(path, options)` in `api/client.ts`. All types in `api/types.ts`. -- **Hooks**: All data fetching in `hooks/useApi.ts` using `useQuery` / `useMutation`. -- **Module gating**: Navigation items conditionally rendered based on `EnabledModules` from system status. - -### Database - -- **PostgreSQL only.** SQLite is used read-only for migration imports from *arr databases, and by the standalone `stackarr-bootstrap` binary for its own persistence (`server_names`, `pending_claims` tables). -- 4 migration files: `001_initial.sql`, `002_streaming.sql`, `003_health_check.sql`, `004_remote_access.sql`. Add new as `005_*.sql`, etc. -- JSONB columns for flexible data: `quality`, `languages`, `images`, `config`, `items`, `custom_data`. -- Array columns: `genres TEXT[]`, `tags INT[]`, `categories INT[]`. -- All timestamps are `TIMESTAMPTZ` (UTC). - -## Sub-Documentation - -| Document | Covers | -|----------|--------| -| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, data flow, module relationships, concurrency model | -| [docs/CRATE-GUIDE.md](docs/CRATE-GUIDE.md) | Every crate's purpose, public API, dependencies, and usage | -| [docs/API-REFERENCE.md](docs/API-REFERENCE.md) | All REST endpoints, request/response shapes, error handling | -| [docs/DATABASE.md](docs/DATABASE.md) | Schema, models, migration patterns, query conventions | -| [docs/FRONTEND.md](docs/FRONTEND.md) | React architecture, components, state management, styling | -| [docs/CONFIGURATION.md](docs/CONFIGURATION.md) | TOML config, env vars, CLI args, feature flags, hot reload | -| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev setup, testing, building, debugging, code style | -| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | Docker, CI/CD pipeline, production config, s6 services | -| [docs/DOMAIN-MODELS.md](docs/DOMAIN-MODELS.md) | Core types, enums, relationships, serialization | -| [docs/PARSER.md](docs/PARSER.md) | Release name parsing engine — quality, episodes, languages | -| [docs/DOWNLOAD-IMPORT.md](docs/DOWNLOAD-IMPORT.md) | Download client abstraction, import pipeline, file renaming | -| [docs/streaming.md](docs/streaming.md) | Video streaming architecture — HLS, ffmpeg, direct play | diff --git a/.claude/worktrees/agent-aa2f0435/Cargo.lock b/.claude/worktrees/agent-aa2f0435/Cargo.lock deleted file mode 100644 index 770072e4..00000000 --- a/.claude/worktrees/agent-aa2f0435/Cargo.lock +++ /dev/null @@ -1,6983 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - -[[package]] -name = "argon2" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "assert_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e2651f366b7ee3f97729fded1441539b49d5f39eeb05b842689e11e84501b2" -dependencies = [ - "const_panic", -] - -[[package]] -name = "async-backtrace" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcb391558246d27a13f195c1e3a53eda422270fdd452bd57a5aa9c1da1bb198" -dependencies = [ - "async-backtrace-attributes", - "dashmap 5.5.3", - "futures", - "loom", - "once_cell", - "pin-project-lite", - "rustc-hash 1.1.0", - "static_assertions", -] - -[[package]] -name = "async-backtrace-attributes" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbba0d438add06462a0371997575927bc05052f7ec486e7a4ca405c956c3d7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-compression" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "aws-lc-rs" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "sync_wrapper", - "tower 0.5.3", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core 0.5.6", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.8.4", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-extra" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" -dependencies = [ - "axum 0.8.8", - "axum-core 0.5.6", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "serde_core", - "serde_html_form", - "serde_path_to_error", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bip39" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" -dependencies = [ - "bitcoin_hashes", - "serde", - "unicode-normalization", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" -dependencies = [ - "hex-conservative", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.2.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chardetng" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" -dependencies = [ - "cfg-if", - "encoding_rs", - "memchr", -] - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "commoncrypto" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d056a8586ba25a1e4d61cb090900e495952c7886786fc55f909ab2f819b69007" -dependencies = [ - "commoncrypto-sys", -] - -[[package]] -name = "commoncrypto-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fed34f46747aa73dfaa578069fd8279d2818ade2b55f38f22a9401c7f4083e2" -dependencies = [ - "libc", -] - -[[package]] -name = "compression-codecs" -version = "0.4.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" -dependencies = [ - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "console-api" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8030735ecb0d128428b64cd379809817e620a40e5001c54465b99ec5feec2857" -dependencies = [ - "futures-core", - "prost", - "prost-types", - "tonic", - "tracing-core", -] - -[[package]] -name = "console-subscriber" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6539aa9c6a4cd31f4b1c040f860a1eac9aa80e7df6b05d506a6e7179936d6a01" -dependencies = [ - "console-api", - "crossbeam-channel", - "crossbeam-utils", - "futures-task", - "hdrhistogram", - "humantime", - "hyper-util", - "prost", - "prost-types", - "serde", - "serde_json", - "thread_local", - "tokio", - "tokio-stream", - "tonic", - "tracing", - "tracing-core", - "tracing-subscriber", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const_panic" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" -dependencies = [ - "typewit", -] - -[[package]] -name = "constant_time_eq" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array 0.14.7", - "typenum", -] - -[[package]] -name = "crypto-hash" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a77162240fd97248d19a564a565eb563a3f592b386e4136fb300909e67dddca" -dependencies = [ - "commoncrypto", - "hex 0.3.2", - "openssl", - "winapi", -] - -[[package]] -name = "cssparser" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", - "serde", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deadpool" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" -dependencies = [ - "deadpool-runtime", - "lazy_static", - "num_cpus", - "tokio", -] - -[[package]] -name = "deadpool-runtime" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" - -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dontfrag" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117949f5a8b25ba471b4dfea927a07a2f8730c585532a2eb4294e18f5155397" -dependencies = [ - "cfg-if", - "libc", - "tokio", - "windows-sys 0.52.0", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ego-tree" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "feed-rs" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" -dependencies = [ - "chrono", - "mediatype", - "quick-xml", - "regex", - "serde", - "serde_json", - "siphasher", - "url", - "uuid", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "generator" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "windows 0.48.0", -] - -[[package]] -name = "generic-array" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" -dependencies = [ - "typenum", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "governor" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" -dependencies = [ - "cfg-if", - "dashmap 6.1.0", - "futures-sink", - "futures-timer", - "futures-util", - "getrandom 0.3.4", - "no-std-compat", - "nonzero_ext", - "parking_lot", - "portable-atomic", - "quanta", - "rand 0.9.2", - "smallvec", - "spinning_top", - "web-time", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashlink" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "hdrhistogram" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" -dependencies = [ - "base64 0.21.7", - "byteorder", - "flate2", - "nom", - "num-traits", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "805026a5d0141ffc30abb3be3173848ad46a1b1664fe632428479619a3644d77" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-conservative" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.6", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags 2.11.0", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array 0.14.7", -] - -[[package]] -name = "intervaltree" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "270bc34e57047cab801a8c871c124d9dc7132f6473c6401f645524f4e6edd111" -dependencies = [ - "smallvec", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "leaky-bucket" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a396bb213c2d09ed6c5495fd082c991b6ab39c9daf4fff59e6727f85c73e4c5" -dependencies = [ - "parking_lot", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.183" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" -dependencies = [ - "bitflags 2.11.0", - "libc", - "plain", - "redox_syscall 0.7.3", -] - -[[package]] -name = "librqbit-dualstack-sockets" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7189f1ff66ca75055137a38b062ba7df9691d071b7172e5090997c62eb4b4de" -dependencies = [ - "axum 0.8.8", - "backon", - "futures", - "libc", - "network-interface", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "librqbit-utp" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f3bfdc73944bc76cab24d5690a98816770040a654c449edf5ff2b9ba22626aa" -dependencies = [ - "bitvec", - "dontfrag", - "lazy_static", - "libc", - "librqbit-dualstack-sockets", - "metrics", - "parking_lot", - "rand 0.9.2", - "ringbuf", - "rustc-hash 2.1.1", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "librtbit" -version = "0.0.1" -dependencies = [ - "anyhow", - "arc-swap", - "async-backtrace", - "async-compression", - "async-stream", - "async-trait", - "axum 0.8.8", - "axum-extra", - "backon", - "base64 0.22.1", - "bincode", - "bitvec", - "byteorder", - "bytes", - "chrono", - "console-subscriber", - "crc32fast", - "dashmap 6.1.0", - "feed-rs", - "futures", - "governor", - "hex 0.4.3", - "home", - "http", - "intervaltree", - "itertools 0.14.0", - "librqbit-dualstack-sockets", - "librqbit-utp", - "librtbit-bencode", - "librtbit-buffers", - "librtbit-clone-to-owned", - "librtbit-core", - "librtbit-dht", - "librtbit-lsd", - "librtbit-peer-protocol", - "librtbit-sha1-wrapper", - "librtbit-tracker-comms", - "librtbit-upnp", - "librtbit-upnp-serve", - "lru", - "memmap2", - "metrics-exporter-prometheus", - "mime_guess", - "nix", - "notify", - "parking_lot", - "pollster", - "rand 0.9.2", - "regex", - "reqwest", - "rlimit", - "rusqlite", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "serde_with", - "size_format", - "socket2 0.6.3", - "sqlx", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-socks", - "tokio-stream", - "tokio-test", - "tokio-util", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "urlencoding", - "utoipa", - "utoipa-axum", - "utoipa-swagger-ui", - "uuid", - "walkdir", - "windows 0.58.0", -] - -[[package]] -name = "librtbit-bencode" -version = "3.1.0" -dependencies = [ - "anyhow", - "arrayvec", - "atoi", - "bytes", - "librtbit-buffers", - "librtbit-clone-to-owned", - "serde", - "serde_derive", - "thiserror 2.0.18", -] - -[[package]] -name = "librtbit-buffers" -version = "4.2.0" -dependencies = [ - "bytes", - "librtbit-clone-to-owned", - "serde", - "serde_derive", -] - -[[package]] -name = "librtbit-clone-to-owned" -version = "3.0.1" -dependencies = [ - "bytes", -] - -[[package]] -name = "librtbit-core" -version = "5.0.0" -dependencies = [ - "anyhow", - "bytes", - "chardetng", - "data-encoding", - "directories", - "encoding_rs", - "hex 0.4.3", - "itertools 0.14.0", - "librtbit-bencode", - "librtbit-buffers", - "librtbit-clone-to-owned", - "librtbit-sha1-wrapper", - "memchr", - "parking_lot", - "rand 0.9.2", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "url", -] - -[[package]] -name = "librtbit-dht" -version = "5.3.0" -dependencies = [ - "anyhow", - "aws-lc-rs", - "backon", - "bytes", - "chrono", - "dashmap 6.1.0", - "futures", - "indexmap 2.13.0", - "leaky-bucket", - "librqbit-dualstack-sockets", - "librtbit-bencode", - "librtbit-buffers", - "librtbit-clone-to-owned", - "librtbit-core", - "librtbit-sha1-wrapper", - "parking_lot", - "rand 0.9.2", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "librtbit-lsd" -version = "0.1.0" -dependencies = [ - "anyhow", - "atoi", - "bstr", - "futures", - "httparse", - "librqbit-dualstack-sockets", - "librtbit-core", - "librtbit-sha1-wrapper", - "parking_lot", - "rand 0.9.2", - "tokio", - "tokio-util", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "librtbit-peer-protocol" -version = "4.3.0" -dependencies = [ - "anyhow", - "bitvec", - "byteorder", - "bytes", - "criterion", - "itertools 0.14.0", - "librtbit-bencode", - "librtbit-buffers", - "librtbit-clone-to-owned", - "librtbit-core", - "serde", - "serde_derive", - "thiserror 2.0.18", - "tracing", -] - -[[package]] -name = "librtbit-sha1-wrapper" -version = "4.1.0" -dependencies = [ - "assert_cfg", - "aws-lc-rs", - "crypto-hash", -] - -[[package]] -name = "librtbit-tracker-comms" -version = "3.0.0" -dependencies = [ - "anyhow", - "async-stream", - "backon", - "byteorder", - "futures", - "itertools 0.14.0", - "librqbit-dualstack-sockets", - "librtbit-bencode", - "librtbit-buffers", - "librtbit-core", - "parking_lot", - "rand 0.9.2", - "reqwest", - "serde", - "serde_derive", - "serde_with", - "tokio", - "tokio-util", - "tracing", - "url", - "urlencoding", -] - -[[package]] -name = "librtbit-upnp" -version = "1.0.0" -dependencies = [ - "anyhow", - "bstr", - "futures", - "httparse", - "librqbit-dualstack-sockets", - "network-interface", - "quick-xml", - "reqwest", - "serde", - "serde_derive", - "socket2 0.6.3", - "tokio", - "tracing", - "tracing-subscriber", - "url", -] - -[[package]] -name = "librtbit-upnp-serve" -version = "1.0.1" -dependencies = [ - "anyhow", - "axum 0.8.8", - "bstr", - "futures", - "gethostname", - "http", - "httparse", - "librqbit-dualstack-sockets", - "librtbit-core", - "librtbit-upnp", - "mime_guess", - "network-interface", - "parking_lot", - "quick-xml", - "rand 0.9.2", - "reqwest", - "serde", - "serde_derive", - "tokio", - "tokio-util", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "uuid", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", - "md5-asm", -] - -[[package]] -name = "md5-asm" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19b8ee7fc7d812058d3b708c7f719efd0713d53854648e4223c6fcae709e2df" -dependencies = [ - "cc", -] - -[[package]] -name = "mediatype" -version = "0.19.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" -dependencies = [ - "serde", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "metrics" -version = "0.24.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" -dependencies = [ - "ahash", - "portable-atomic", -] - -[[package]] -name = "metrics-exporter-prometheus" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b166dea96003ee2531cf14833efedced545751d800f03535801d833313f8c15" -dependencies = [ - "base64 0.22.1", - "indexmap 2.13.0", - "metrics", - "metrics-util", - "quanta", - "thiserror 2.0.18", -] - -[[package]] -name = "metrics-util" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdfb1365fea27e6dd9dc1dbc19f570198bc86914533ad639dae939635f096be4" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", - "hashbrown 0.16.1", - "metrics", - "quanta", - "rand 0.9.2", - "rand_xoshiro", - "sketches-ddsketch", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "network-interface" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" -dependencies = [ - "cc", - "libc", - "thiserror 2.0.18", - "winapi", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "no-std-compat" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nonzero_ext" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" - -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.0", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" -dependencies = [ - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-complex" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "nzb-core" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "parking_lot", - "quick-xml", - "rusqlite", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "toml", - "tracing", - "uuid", -] - -[[package]] -name = "nzb-decode" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "crc32fast", - "nzb-core", - "parking_lot", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "yenc-simd", -] - -[[package]] -name = "nzb-nntp" -version = "0.1.0" -dependencies = [ - "anyhow", - "arc-swap", - "bytes", - "flate2", - "governor", - "nzb-core", - "parking_lot", - "rustls", - "rustls-pki-types", - "thiserror 2.0.18", - "tokio", - "tokio-rustls", - "tracing", - "webpki-roots 0.26.11", -] - -[[package]] -name = "nzb-postproc" -version = "0.1.0" -dependencies = [ - "anyhow", - "nzb-core", - "rust-par2", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "walkdir", - "zip 2.4.2", -] - -[[package]] -name = "nzb-web" -version = "0.1.0" -dependencies = [ - "anyhow", - "arc-swap", - "axum 0.8.8", - "base64 0.22.1", - "chrono", - "feed-rs", - "governor", - "hex 0.4.3", - "http", - "libc", - "mime_guess", - "notify", - "nzb-core", - "nzb-decode", - "nzb-nntp", - "nzb-postproc", - "parking_lot", - "rand 0.9.2", - "regex", - "reqwest", - "rust-embed", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tower 0.5.3", - "tower-http", - "tracing", - "tracing-subscriber", - "utoipa", - "utoipa-swagger-ui", - "uuid", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "openssl" -version = "0.10.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.112" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" -dependencies = [ - "prost", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - -[[package]] -name = "quanta" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" -dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi", - "web-sys", - "winapi", -] - -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "encoding_rs", - "memchr", - "serde", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.1", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash 2.1.1", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_syscall" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "cookie", - "cookie_store", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "native-tls", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tokio-rustls", - "tokio-util", - "tower 0.5.3", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots 1.0.6", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "ringbuf" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe47b720588c8702e34b5979cb3271a8b1842c7cb6f57408efa70c779363488c" -dependencies = [ - "crossbeam-utils", - "portable-atomic", - "portable-atomic-util", -] - -[[package]] -name = "rlimit" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" -dependencies = [ - "libc", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rusqlite" -version = "0.32.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" -dependencies = [ - "bitflags 2.11.0", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink 0.9.1", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "sha2", - "walkdir", -] - -[[package]] -name = "rust-par2" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b1b121bbc5e2f7b622361ed170de7f1881361b7a1093781e71e67ad6bbdb88" -dependencies = [ - "crc32fast", - "md-5", - "rayon", - "thiserror 2.0.18", - "tracing", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scraper" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" -dependencies = [ - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "precomputed-hash", - "selectors", - "tendril", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" -dependencies = [ - "bitflags 2.11.0", - "cssparser", - "derive_more", - "fxhash", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_html_form" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" -dependencies = [ - "form_urlencoded", - "indexmap 2.13.0", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex 0.4.3", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.13.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "size_format" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed5f6ab2122c6dec69dca18c72fa4590a27e581ad20d44960fe74c032a0b23b" -dependencies = [ - "generic-array 0.12.4", - "num", -] - -[[package]] -name = "sketches-ddsketch" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spinning_top" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64 0.22.1", - "bytes", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink 0.10.0", - "indexmap 2.13.0", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex 0.4.3", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.11.0", - "byteorder", - "bytes", - "chrono", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array 0.14.7", - "hex 0.4.3", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.5", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64 0.22.1", - "bitflags 2.11.0", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex 0.4.3", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.5", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror 2.0.18", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "chrono", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.18", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stackarr" -version = "0.1.0" -dependencies = [ - "anyhow", - "arc-swap", - "clap", - "librtbit", - "librtbit-upnp", - "network-interface", - "nzb-core", - "nzb-web", - "reqwest", - "serde", - "serde_json", - "sqlx", - "stackarr-cardigann", - "stackarr-core", - "stackarr-download", - "stackarr-indexer", - "stackarr-migrate", - "stackarr-scheduler", - "stackarr-stream", - "stackarr-web", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "stackarr-bootstrap" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum 0.8.8", - "bip39", - "chrono", - "clap", - "dashmap 6.1.0", - "rand 0.9.2", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "sha2", - "tokio", - "toml", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "stackarr-cardigann" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "indexmap 2.13.0", - "regex", - "reqwest", - "scraper", - "serde", - "serde_json", - "serde_yaml", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "urlencoding", -] - -[[package]] -name = "stackarr-cardigann-parity" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "clap", - "regex", - "reqwest", - "serde", - "serde_json", - "stackarr-cardigann", - "stackarr-indexer", - "tokio", - "tracing", - "tracing-subscriber", - "urlencoding", -] - -[[package]] -name = "stackarr-core" -version = "0.1.0" -dependencies = [ - "anyhow", - "arc-swap", - "argon2", - "base64 0.22.1", - "chrono", - "hex 0.4.3", - "rand 0.9.2", - "serde", - "serde_json", - "sha2", - "sqlx", - "thiserror 2.0.18", - "tokio", - "toml", - "tracing", - "uuid", -] - -[[package]] -name = "stackarr-download" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "librtbit", - "librtbit-core", - "nzb-core", - "nzb-web", - "reqwest", - "serde", - "serde_json", - "stackarr-core", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "stackarr-import" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "once_cell", - "regex", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "stackarr-media", - "stackarr-parser", - "stackarr-quality", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", - "walkdir", -] - -[[package]] -name = "stackarr-indexer" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "futures", - "quick-xml", - "reqwest", - "serde", - "serde_json", - "sqlx", - "stackarr-cardigann", - "stackarr-core", - "stackarr-parser", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "urlencoding", -] - -[[package]] -name = "stackarr-media" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "reqwest", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "stackarr-metadata", - "stackarr-parser", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "stackarr-metadata" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "leaky-bucket", - "lru", - "parking_lot", - "reqwest", - "serde", - "serde_json", - "stackarr-core", - "thiserror 2.0.18", - "tokio", - "tracing", - "wiremock", -] - -[[package]] -name = "stackarr-migrate" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "rusqlite", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "stackarr-parser", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "stackarr-notify" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "reqwest", - "serde", - "serde_json", - "stackarr-core", - "thiserror 2.0.18", - "tokio", - "tracing", - "wiremock", -] - -[[package]] -name = "stackarr-parser" -version = "0.1.0" -dependencies = [ - "chrono", - "once_cell", - "regex", - "serde", - "serde_json", - "thiserror 2.0.18", - "tracing", -] - -[[package]] -name = "stackarr-plex" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "regex", - "reqwest", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "stackarr-metadata", - "thiserror 2.0.18", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "stackarr-quality" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "regex", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "stackarr-parser", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "stackarr-scheduler" -version = "0.1.0" -dependencies = [ - "anyhow", - "chrono", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "stackarr-download", - "stackarr-import", - "stackarr-indexer", - "stackarr-media", - "stackarr-metadata", - "stackarr-plex", - "stackarr-quality", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "stackarr-stream" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "chrono", - "dashmap 6.1.0", - "mime_guess", - "reqwest", - "serde", - "serde_json", - "sqlx", - "stackarr-core", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", -] - -[[package]] -name = "stackarr-web" -version = "0.1.0" -dependencies = [ - "anyhow", - "arc-swap", - "axum 0.8.8", - "bytes", - "chrono", - "dashmap 6.1.0", - "governor", - "http-body-util", - "librtbit", - "nzb-core", - "nzb-nntp", - "nzb-web", - "reqwest", - "serde", - "serde_json", - "serde_urlencoded", - "serde_yaml", - "sha2", - "sqlx", - "stackarr-cardigann", - "stackarr-core", - "stackarr-download", - "stackarr-import", - "stackarr-indexer", - "stackarr-media", - "stackarr-metadata", - "stackarr-migrate", - "stackarr-notify", - "stackarr-plex", - "stackarr-quality", - "stackarr-scheduler", - "stackarr-stream", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tower 0.5.3", - "tower-http", - "tracing", - "url", - "utoipa", - "utoipa-swagger-ui", - "uuid", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.11.0", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.3", - "tokio-macros", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-socks" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tonic" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" -dependencies = [ - "async-stream", - "async-trait", - "axum 0.7.9", - "base64 0.22.1", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "prost", - "socket2 0.5.10", - "tokio", - "tokio-stream", - "tower 0.4.13", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 1.9.3", - "pin-project", - "pin-project-lite", - "rand 0.8.5", - "slab", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.0", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "iri-string", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "time", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "typewit" -version = "1.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "utoipa" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" -dependencies = [ - "indexmap 2.13.0", - "serde", - "serde_json", - "utoipa-gen", -] - -[[package]] -name = "utoipa-axum" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" -dependencies = [ - "axum 0.8.8", - "paste", - "tower-layer", - "tower-service", - "utoipa", -] - -[[package]] -name = "utoipa-gen" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn", -] - -[[package]] -name = "utoipa-swagger-ui" -version = "9.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" -dependencies = [ - "axum 0.8.8", - "base64 0.22.1", - "mime_guess", - "regex", - "rust-embed", - "serde", - "serde_json", - "url", - "utoipa", - "zip 3.0.0", -] - -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "wiremock" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" -dependencies = [ - "assert-json-diff", - "base64 0.22.1", - "deadpool", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.13.0", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "yenc-simd" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af469088d22edb5c63bd83b7d9f005441330721fbbc122ff393cbcdcaa16a52" -dependencies = [ - "crc32fast", - "thiserror 2.0.18", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", - "flate2", - "getrandom 0.3.4", - "hmac", - "indexmap 2.13.0", - "lzma-rs", - "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.18", - "time", - "xz2", - "zeroize", - "zopfli", - "zstd", -] - -[[package]] -name = "zip" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" -dependencies = [ - "arbitrary", - "crc32fast", - "flate2", - "indexmap 2.13.0", - "memchr", - "zopfli", -] - -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/.claude/worktrees/agent-aa2f0435/Cargo.toml b/.claude/worktrees/agent-aa2f0435/Cargo.toml deleted file mode 100644 index b80c28fe..00000000 --- a/.claude/worktrees/agent-aa2f0435/Cargo.toml +++ /dev/null @@ -1,266 +0,0 @@ -[workspace] -resolver = "3" -exclude = ["client/src-tauri"] -members = [ - "crates/stackarr-core", - "crates/stackarr-media", - "crates/stackarr-parser", - "crates/stackarr-quality", - "crates/stackarr-indexer", - "crates/stackarr-download", - "crates/stackarr-import", - "crates/stackarr-scheduler", - "crates/stackarr-metadata", - "crates/stackarr-web", - "crates/stackarr-notify", - "crates/stackarr-migrate", - "crates/stackarr-plex", - "crates/stackarr-cardigann", - "crates/stackarr-cardigann-parity", - "crates/stackarr-stream", - "crates/stackarr-bootstrap", - # Torrent engine (vendored from rustTorrent) - "crates/torrent/librtbit", - "crates/torrent/librtbit_core", - "crates/torrent/bencode", - "crates/torrent/buffers", - "crates/torrent/clone_to_owned", - "crates/torrent/dht", - "crates/torrent/librtbit_lsd", - "crates/torrent/peer_binary_protocol", - "crates/torrent/sha1w", - "crates/torrent/tracker_comms", - "crates/torrent/upnp", - "crates/torrent/upnp-serve", - # Usenet engine (vendored from rustnzbd) - "crates/usenet/nzb-core", - "crates/usenet/nzb-decode", - "crates/usenet/nzb-nntp", - "crates/usenet/nzb-postproc", - "crates/usenet/nzb-web", -] - -[workspace.package] -version = "0.1.0" -edition = "2024" -license = "MIT" -repository = "https://github.com/AusAgentSmith-org/StackArr" - -[workspace.lints.clippy] -all = { level = "warn", priority = -1 } - -[workspace.lints.rust] -unused = "warn" - -[workspace.dependencies] -# Async runtime -tokio = { version = "1", features = ["full"] } -tokio-stream = "0.1" -tokio-util = "0.7" -tokio-socks = "0.5" -tokio-rustls = "0.26" -tokio-test = "0.4" - -# Web framework -axum = "0.8" -axum-extra = "0.10" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace", "fs", "set-header"] } -http = "1" - -# Database -sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "chrono", "json", "uuid", "migrate"] } -rusqlite = { version = "0.32", features = ["bundled"] } - -# HTTP client -reqwest = { version = "0.12", features = ["json", "rustls-tls", "cookies", "multipart"] } - -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" -serde_derive = "1" -serde_urlencoded = "0.7" -serde_with = "3" -toml = "0.8" -quick-xml = "0.37" -serde_yaml = "0.9" -scraper = "0.22" -bincode = "1" - -# TLS -rustls = { version = "0.23", features = ["ring"] } -rustls-pki-types = "1" -webpki-roots = "0.26" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "registry", "time"] } -tracing-appender = "0.2" - -# Error handling -thiserror = "2" -anyhow = "1" - -# Util -uuid = { version = "1", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } -regex = "1" -async-trait = "0.1" -parking_lot = "0.12" -arc-swap = "1" -futures = "0.3" -bytes = "1" -url = "2" -hex = "0.4" -base64 = "0.22" -rand = "0.9" -itertools = "0.14" -dashmap = "6" -indexmap = "2" -lru = "0.16" -walkdir = "2" -tempfile = "3" -directories = "6" -home = "0.5" -gethostname = "1" -libc = "0.2" - -# Encoding / hashing -crc32fast = "1" -sha2 = "0.10" -data-encoding = "2" -chardetng = "0.1" -encoding_rs = "0.8" -crypto-hash = "0.3" -flate2 = "1" - -# Networking -network-interface = "2" -socket2 = "0.6" -httparse = "1" -bstr = "1" -librqbit-dualstack-sockets = "0.7" -librqbit-utp = "0.7" -nix = "0.30" - -# Bencode / torrent -arrayvec = "0.7" -atoi = "2" -bitvec = "1" -byteorder = "1" -memchr = "2" -memmap2 = "0.9" -intervaltree = "0.2" - -# Rate limiting / backoff -governor = "0.8" -leaky-bucket = "1.1" -backon = "1.5" - -# Async utilities -async-stream = "0.3" -async-compression = "0.4" -async-backtrace = "0.2" - -# Metrics -metrics-exporter-prometheus = { version = "0.17", default-features = false } - -# Misc -size_format = "1" -rlimit = "0.10" -mime_guess = { version = "2", default-features = false } -urlencoding = "2" -parse_duration = "2" -pollster = "0.4" -signal-hook = "0.3" -aws-lc-rs = "1.12" -openssl = "0.10" -notify = "8" -assert_cfg = "0.1" -console-subscriber = "0.4" -criterion = "0.5" -windows = "0.58" -feed-rs = "2" -wiremock = "0.6" -http-body-util = "0.1" -rust-par2 = "0.1" -yenc-simd = "0.1" - -# API docs -utoipa = { version = "5", features = ["axum_extras"] } -utoipa-axum = "0.2" -utoipa-swagger-ui = { version = "9", features = ["axum"] } - -# StackArr internal crates -stackarr-core = { path = "crates/stackarr-core" } -stackarr-media = { path = "crates/stackarr-media" } -stackarr-parser = { path = "crates/stackarr-parser" } -stackarr-quality = { path = "crates/stackarr-quality" } -stackarr-indexer = { path = "crates/stackarr-indexer" } -stackarr-download = { path = "crates/stackarr-download" } -stackarr-import = { path = "crates/stackarr-import" } -stackarr-scheduler = { path = "crates/stackarr-scheduler" } -stackarr-metadata = { path = "crates/stackarr-metadata" } -stackarr-web = { path = "crates/stackarr-web" } -stackarr-notify = { path = "crates/stackarr-notify" } -stackarr-migrate = { path = "crates/stackarr-migrate" } -stackarr-plex = { path = "crates/stackarr-plex" } -stackarr-cardigann = { path = "crates/stackarr-cardigann" } -stackarr-stream = { path = "crates/stackarr-stream" } - -# Torrent internal crates -librtbit = { path = "crates/torrent/librtbit", default-features = false } -bencode = { path = "crates/torrent/bencode", package = "librtbit-bencode", default-features = false } -buffers = { path = "crates/torrent/buffers", package = "librtbit-buffers" } -clone_to_owned = { path = "crates/torrent/clone_to_owned", package = "librtbit-clone-to-owned" } -librtbit-core = { path = "crates/torrent/librtbit_core", default-features = false } -dht = { path = "crates/torrent/dht", package = "librtbit-dht", default-features = false } -librtbit-lsd = { path = "crates/torrent/librtbit_lsd", default-features = false } -peer_binary_protocol = { path = "crates/torrent/peer_binary_protocol", package = "librtbit-peer-protocol", default-features = false } -sha1w = { path = "crates/torrent/sha1w", package = "librtbit-sha1-wrapper", default-features = false } -tracker_comms = { path = "crates/torrent/tracker_comms", package = "librtbit-tracker-comms", default-features = false } -librtbit-upnp = { path = "crates/torrent/upnp" } -upnp-serve = { path = "crates/torrent/upnp-serve", package = "librtbit-upnp-serve", default-features = false } - -# Usenet internal crates -nzb-core = { path = "crates/usenet/nzb-core" } -nzb-web = { path = "crates/usenet/nzb-web" } -nzb-nntp = { path = "crates/usenet/nzb-nntp" } -nzb-decode = { path = "crates/usenet/nzb-decode" } -nzb-postproc = { path = "crates/usenet/nzb-postproc" } - -[package] -name = "stackarr" -version.workspace = true -edition.workspace = true -license.workspace = true - -[features] -default = ["ui"] -ui = [] - -[dependencies] -stackarr-core = { workspace = true } -stackarr-web = { workspace = true } -stackarr-scheduler = { workspace = true } -stackarr-download = { workspace = true } -stackarr-indexer = { workspace = true } -stackarr-migrate = { workspace = true } -stackarr-cardigann = { workspace = true } -stackarr-stream = { workspace = true } -librtbit = { workspace = true } -nzb-web = { workspace = true } -nzb-core = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -anyhow = { workspace = true } -arc-swap = { workspace = true } -clap = { version = "4", features = ["derive", "env"] } -sqlx = { workspace = true } -serde_json = { workspace = true } -reqwest = { workspace = true } -uuid = { workspace = true } -network-interface = { workspace = true } -librtbit-upnp = { workspace = true } -serde = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/IMPLEMENTATION_PLAN.md b/.claude/worktrees/agent-aa2f0435/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 3b2d89a7..00000000 --- a/.claude/worktrees/agent-aa2f0435/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,318 +0,0 @@ -# StackArr — Implementation Plan - -## Current State (updated 2026-03-26) - -**29 crates**, 74K lines of Rust, 686 tests passing. React UI with 15 pages, 6K lines TypeScript. Full Docker build + CI/CD pipeline. Deployed to Node B (192.168.0.30). - -**Architecture change**: rustTorrent and rustnzbd engines vendored directly into the workspace (not sidecars). Single binary includes media management + torrent engine + usenet engine. No external download clients required (but still supported via qBit/Transmission/SABnzbd/NZBGet API clients). - -### Crate Status - -| Crate | % Done | State | -|-------|--------|-------| -| stackarr-core | 100% | Models, config with defaults, DB pool, migrations, enabled modules | -| stackarr-parser | 100% | Release name parser, 75 tests | -| stackarr-media | 100% | Series/Movie/Episode CRUD + calendar + wanted + metadata refresh services | -| stackarr-web | 100% | All routes working (50+ endpoints), search/grab fully wired | -| stackarr-download | 90% | qBit, Transmission, SABnzbd, NZBGet + embedded torrent/usenet clients; DownloadClientManager exists but not in AppState | -| stackarr-indexer | 90% | Newznab/Torznab client working; IndexarrClient substantially complete; IndexerManager implemented but not in AppState | -| stackarr-metadata | 85% | TMDB client, no caching or rate limiting | -| stackarr-migrate | 100% | Sonarr/Radarr/Prowlarr SQLite readers + Postgres writer with ID mapping | -| stackarr-import | 90% | Disk scan + naming tokens + process_completed_download pipeline | -| stackarr-scheduler | 80% | Real metadata refresh task; import scan task; RSS sync is no-op stub; missing_search_task absent | -| stackarr-notify | 80% | Webhook + Discord; Telegram/Slack/email missing | -| stackarr-quality | 100% | CRUD works; 9/9 specs implemented with real logic; 46 tests passing | -| **Torrent engine** (12 crates) | 100% | Vendored from rustTorrent — librtbit, DHT, trackers, bencode, etc. | -| **Usenet engine** (5 crates) | 100% | Vendored from rustnzbd — NNTP, yEnc, par2, post-processing | - -### React UI Status - -| Page | Status | -|------|--------| -| First Boot Wizard | Done | -| Series List + Detail | Done | -| Movie List + Detail | Done | -| Calendar | Done | -| Queue | Done | -| Torrents (full client UI) | Done | -| Usenet (queue + history + NNTP servers) | Done | -| History | Done | -| Wanted/Missing | Done | -| Settings (7 tabs) | Done | -| Migration | Done | - -### Infrastructure - -| Component | Status | -|-----------|--------| -| Docker multi-stage build (Node + Rust + slim runtime) | Done | -| docker-compose.yml (dev) | Done | -| docker-compose.prod.yml (Node B with media mounts RO) | Done | -| GitHub Actions CI/CD (check → build → smoke test → deploy) | Done | -| Repo: github.com/AusAgentSmith-org/StackArr | Done | -| GHCR: ghcr.io/ausagentsmith-org/stackarr | Done | - ---- - -## ~~Phase 0 — Make It Boot~~ COMPLETE - -- ~~`src/main.rs` — DATABASE_URL env, auto-generate default config~~ -- ~~`config.rs` — Default impls, generate_default()~~ -- ~~`db.rs` — Migration runner, enabled_modules queries~~ -- ~~`docker/Dockerfile` — Multi-stage build~~ -- ~~`docker/docker-compose.yml` — StackArr + Postgres 17~~ -- ~~`config.example.toml`~~ - -Verified: `docker compose up --build` → API + UI served on port 9111. - ---- - -## ~~Phase 1 — First-Boot Wizard + Core CRUD~~ COMPLETE - -- ~~POST /api/v1/setup/init — persist modules, root folders, API key~~ -- ~~GET /api/v1/system/status — real first_boot from DB~~ -- ~~CRUD routes: root folders, tags, naming config, download clients, indexers~~ -- ~~Health check verifies DB connectivity~~ - ---- - -## ~~Phase 2 — *arr Migration + Library Import~~ COMPLETE - -- ~~stackarr-migrate crate: sonarr.rs, radarr.rs, prowlarr.rs, writer.rs~~ -- ~~CLI: `stackarr migrate --sonarr --radarr --prowlarr [--dry-run]`~~ -- ~~API: POST /api/v1/system/migrate (multipart upload)~~ -- ~~Disk scan: POST /api/v1/command {"name":"DiskScan"}~~ -- ~~TMDB lookup: GET /api/v1/series/lookup, /movies/lookup~~ - -Real *arr backup DBs at TestData/arr-backups/ (535 series, 1212 movies, 6 indexers). - ---- - -## ~~Phase 3 — Library Views + Metadata Refresh~~ COMPLETE - -- ~~GET /api/v1/calendar?start=&end=~~ -- ~~GET /api/v1/wanted/missing, /wanted/cutoff~~ -- ~~GET /api/v1/series/{id}/episodes~~ -- ~~PUT /api/v1/episode/{id}, /episode/monitor (bulk)~~ -- ~~CalendarService, WantedService, MetadataRefreshService~~ -- ~~metadata_refresh_task — real TMDB integration~~ -- ~~Commands: RefreshSeries, RefreshMovie, RefreshAll~~ - ---- - -## ~~Phase 4 — Search + Decision Engine + Grab~~ COMPLETE - -- ~~All 9 decision specifications implemented~~ (QualityAllowedSpec, CutoffSpec, MinSizeSpec, MaxSizeSpec, BlocklistSpec, QueueConflictSpec, MinimumSeedersSpec, CustomFormatScoreSpec, AlreadyImportedSpec) -- ~~`rank_releases()` — quality → seeders → age → indexer priority~~ -- ~~IndexerManager + DownloadClientManager in AppState, loaded from DB at startup~~ -- ~~Search handler: indexer fanout → decision engine → ranking → JSON response~~ -- ~~Grab handler: download client dispatch → queue entry → history entry~~ -- ~~46 quality tests passing~~ - ---- - -## ~~Phase 5 — Download Import Pipeline~~ COMPLETE - -- ~~naming.rs — token system ({Series Title}, S{season:00}E{episode:00}, etc.) with 14 tests~~ -- ~~process_completed_download() — scan → parse → rename → move → DB update~~ -- ~~import_scan_task — polls queue for completed items, runs import, cleans up~~ - ---- - -## Phase 6 — RSS Automation - -**Goal**: Auto-monitor indexer RSS feeds and grab new releases for monitored media. - -**Status**: NOT STARTED. Depends on Phase 4 (needs decision engine). - -**What needs to happen**: -- **6.1** Implement rss_sync_task (fetch RSS → parse → match to media → decision engine → grab) -- **6.2** Add missing_search_task (periodic search for missing media) -- **6.3** Command endpoints: RssSync, SeriesSearch, EpisodeSearch, MovieSearch, MissingSearch -- **6.4** Add `commands` table for async command tracking - ---- - -## Phase 7 — Indexarr Sidecar Integration - -**Goal**: Optional Indexarr as a torrent indexer source. - -**Status**: ~75% COMPLETE — client and infrastructure done, search integration remaining. - -**Done**: -- ~~Complete IndexarrClient (Torznab passthrough + REST + health)~~ — torznab_search, rest_search, status, health_check all implemented (crates/stackarr-indexer/src/indexarr.rs) -- ~~GET /api/v1/indexarr/status route~~ — exists and works (crates/stackarr-web/src/routes/indexarr.rs) -- ~~docker-compose optional Indexarr service~~ — exists with `profiles: [indexarr]` (docker/docker-compose.yml) - -**What still needs to happen**: -- **7.1** Integrate IndexarrClient into search fanout (depends on Phase 4 search handler) - ---- - -## ~~Phase 8 — Embedded Download Clients~~ SUPERSEDED - -~~Original plan: feature-flagged optional embedded clients.~~ - -**Decision changed**: rustTorrent (12 crates) and rustnzbd (5 crates) vendored directly into the workspace. No feature flags — always built in. Single binary includes everything. - -- ~~embedded_torrent.rs wrapping librtbit::Session~~ -- ~~embedded_usenet.rs wrapping nzb_web::QueueManager~~ -- ~~Feature flags removed~~ - -**Additional work done beyond original plan**: -- Full Torrents page in React UI (stats, sortable table, add modal, expand details) -- Full Usenet page in React UI (queue + history + NNTP server management) -- API routes: /api/v1/torrent/* and /api/v1/usenet/* (20 endpoints) -- Sidebar nav updated with Torrents + Usenet tabs -- ~~Wire API stubs to actual librtbit::Session and nzb_web::QueueManager in AppState~~ — engines initialized on boot if enabled in config (src/main.rs:179-293) - ---- - -## ~~Phase 9 — React UI~~ COMPLETE - -- ~~15 pages, 6K lines TypeScript~~ -- ~~Dark theme, sidebar nav, TanStack Query~~ -- ~~First-boot wizard, series/movie CRUD, calendar, queue, history, wanted~~ -- ~~Settings (7 tabs), migration upload, torrents, usenet~~ -- ~~Axum serves UI dist with SPA fallback~~ - ---- - -## ~~CI/CD + Docker~~ COMPLETE - -- ~~GitHub Actions: check → build → smoke test → deploy to Node B~~ -- ~~GHCR: ghcr.io/ausagentsmith-org/stackarr~~ -- ~~docker-compose.prod.yml with all media mounts READ ONLY~~ -- ~~Port 9111~~ - ---- - -## Phase 10 — Security Hardening - -**Goal**: Secure the stack before any network exposure beyond localhost. - -**Status**: NOT STARTED. - -**Security audit completed 2026-03-26.** Current state: zero authentication on all 50+ API endpoints, permissive CORS, no rate limiting. The system is fully open to anyone with network access. - -### 10a — Authentication + Authorization (CRITICAL) - -- [ ] **10a.1** Add auth middleware to all API routes — validate API key from `Authorization` header (key already generated at first boot but never checked) -- [ ] **10a.2** Protect `/api/v1/setup/init` — currently the only endpoint with any access control (checks `enabled_count == 0`) -- [ ] **10a.3** Stop returning sensitive data in GET responses — indexer API keys, Plex auth tokens, and download client credentials are all returned verbatim; mask or redact them -- [ ] **10a.4** Session/token auth for the UI (the API key alone is insufficient for browser-based auth) - -### 10b — CORS + CSRF + Headers (CRITICAL) - -- [ ] **10b.1** Replace `CorsLayer::permissive()` (`crates/stackarr-web/src/lib.rs:39`) with explicit origin allowlist -- [ ] **10b.2** Add CSRF protection on state-changing endpoints (POST/PUT/DELETE) -- [ ] **10b.3** Add security response headers: `Content-Security-Policy`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Strict-Transport-Security` - -### 10c — Input Validation + Error Handling (HIGH) - -- [ ] **10c.1** Path traversal — canonicalize and boundary-check user-supplied paths in media library folder CRUD (`crates/stackarr-web/src/routes/medialibraryfolders.rs:98` calls `std::fs::metadata(&body.path)` without canonicalization) -- [ ] **10c.2** URL encoding — Newznab query builder (`crates/stackarr-indexer/src/newznab.rs:113-122`) concatenates parameters without encoding; use `serde_urlencoded` or `url::Url` -- [ ] **10c.3** Sanitize error responses — stop returning raw `format!("database error: {e}")` to clients; log full errors server-side, return generic messages to clients (affects indexers.rs, downloadclients.rs, episodes.rs, system.rs, plex.rs, discover.rs) -- [ ] **10c.4** Validate file type/size on `/api/v1/system/migrate` upload endpoint - -### 10d — TLS + Transport Security (HIGH) - -- [ ] **10d.1** Plex API (`crates/stackarr-plex/src/api.rs:20`) — `danger_accept_invalid_certs(true)` is hardcoded; make configurable, default to verifying certs, allow user opt-out for self-signed setups -- [ ] **10d.2** Usenet NNTP (`crates/usenet/nzb-nntp/src/connection.rs`) — `NoVerifier` accepts any cert, `ssl_verify` defaults to `false`; flip default to `true` -- [ ] **10d.3** Usenet credentials sent via `AUTHINFO PASS` should never appear in logs - -### 10e — Rate Limiting (HIGH) - -- [ ] **10e.1** Add rate limiting middleware — `governor` is already in Cargo.toml but unused on the main API -- [ ] **10e.2** Priority: auth endpoints first, then general API - -### 10f — Docker + Deployment (MEDIUM) - -- [ ] **10f.1** Add `USER` directive to Dockerfile — currently runs as root -- [ ] **10f.2** Audit docker-compose credential handling — database URL with `stackarr:stackarr` in plain text - ---- - -## Phase 11 — Polish + Features - -**Goal**: Production-ready for daily use. - -**Status**: NOT STARTED. - -**Work items**: -- ~~**11.1** Wire embedded torrent/usenet engines to AppState (start session on boot)~~ — DONE (src/main.rs:179-293) -- **11.2** Integration tests (Postgres in Docker, full flow testing) -- **11.3** Real cutoff comparison in wanted/cutoff endpoint (needs quality profile parsing) -- **11.4** Backup/restore (export/import DB as JSON) -- **11.5** Health check system (DB, disk space, client connectivity, indexer health) -- **11.6** Import lists (TMDB popular, Trakt watchlist, IMDB list) -- **11.7** Disk scan on schedule -- **11.8** Scene name mapping / XEM integration for anime -- **11.9** Custom format specification engine (full regex) -- **11.10** Blocklist management UI -- **11.11** Log viewer (WebSocket streaming) -- **11.12** OpenAPI/Swagger docs (utoipa) -- **11.13** Prometheus metrics endpoint -- **11.14** Notification providers: Telegram, Slack, email -- **11.15** TMDB rate limiting / caching - ---- - -## What's Left — Priority Order - -| Priority | Work | Phase | Effort | -|----------|------|-------|--------| -| ~~WI-1~~ | ~~**Finish decision engine** (AlreadyImportedSpec, CustomFormatScoreSpec, managers in AppState)~~ | ~~4~~ | ~~DONE~~ | -| ~~WI-2~~ | ~~**Wire search + grab handlers** (indexer fanout → decision engine → download client → queue/history)~~ | ~~4~~ | ~~DONE~~ | -| WI-3 | **RSS automation** (rss_sync_task, missing_search_task, command endpoints, commands table) | 6 | Medium | -| WI-4 | **Auth + CORS + CSRF** (middleware on all routes, replace permissive CORS, add CSRF tokens) | 10a-b | Medium | -| WI-5 | **Input validation + error sanitization** (path traversal, URL encoding, generic error responses) | 10c | Small | -| WI-6 | **TLS verification defaults** (Plex configurable, Usenet default-on, credential redaction in logs) | 10d | Small | -| WI-7 | **Rate limiting** (wire existing `governor` dep to API middleware) | 10e | Small | -| WI-8 | **Security headers + Docker non-root** | 10b,f | Small | -| WI-9 | **Indexarr search fanout** (wire IndexarrClient into search flow) | 7 | Small | -| WI-10 | **Integration tests** | 11 | Medium | -| WI-11 | **TMDB rate limiting + caching** | 11 | Small | -| WI-12 | **Import lists, scene mapping, custom formats** | 11 | Large | -| WI-13 | **Additional notifications** (Telegram, Slack, email) | 11 | Small | -| WI-14 | **OpenAPI/Swagger + Prometheus metrics** (dependencies present, need wiring) | 11 | Medium | - -### Security Audit Summary (2026-03-26) - -| Category | Severity | Status | -|----------|----------|--------| -| API Authentication | CRITICAL | None — API key generated but never validated | -| CORS | CRITICAL | `CorsLayer::permissive()` allows any origin | -| CSRF | CRITICAL | No tokens, no origin validation | -| Path Traversal | HIGH | User paths not canonicalized | -| TLS Verification | HIGH | Disabled for Plex (hardcoded) and Usenet (default off) | -| Sensitive Data in Responses | HIGH | API keys, Plex tokens, credentials returned in GET | -| Error Leakage | HIGH | Raw DB errors returned to clients | -| Rate Limiting | HIGH | `governor` in Cargo.toml but unused | -| URL Injection | HIGH | Newznab query params not URL-encoded | -| Security Headers | MEDIUM | No CSP, X-Frame-Options, HSTS | -| Docker Root | MEDIUM | No USER directive in Dockerfile | -| Credentials Storage | MEDIUM | Usenet creds in plaintext config | -| File Upload | MEDIUM | No type validation on migrate endpoint | -| **Frontend XSS** | **OK** | No dangerouslySetInnerHTML, eval, innerHTML | -| **SQL Injection** | **OK** | Parameterized sqlx throughout | -| **Dependencies** | **OK** | All current, no known CVEs | -| **Cryptography** | **OK** | Proper libraries (rustls, rand 0.9, aws-lc-rs) | -| **.gitignore** | **OK** | .env, config.toml, secrets excluded | -| **Committed Secrets** | **OK** | None found | - ---- - -## Reference: *arr Database Backups - -| App | Database Path | -|-----|--------------| -| Sonarr | `TestData/arr-backups/sonarr/sonarr.db` (535 series, 31967 episodes, 9113 files) | -| Radarr | `TestData/arr-backups/radarr/radarr.db` (1212 movies, 1128 files) | -| Prowlarr | `TestData/arr-backups/prowlarr/prowlarr.db` (6 indexers) | - -## Repo - -- GitHub: https://github.com/AusAgentSmith-org/StackArr -- GHCR: ghcr.io/ausagentsmith-org/stackarr -- Deploy: Node B (192.168.0.30), port 9111 diff --git a/.claude/worktrees/agent-aa2f0435/PLAN.md b/.claude/worktrees/agent-aa2f0435/PLAN.md deleted file mode 100644 index 5cb26d63..00000000 --- a/.claude/worktrees/agent-aa2f0435/PLAN.md +++ /dev/null @@ -1,1523 +0,0 @@ -# Arz — Unified Media Manager - -## Context - -Replace Sonarr + Radarr + Prowlarr with a single Rust application. The core is a **media manager** — TV series and movie library management with automated search, grab, and organization. Download clients (rustTorrent, rustnzbd) and indexing (Indexarr) are optional embedded modules that can be swapped for external equivalents (qBittorrent, SABnzbd, Prowlarr, etc.). - -**Key design principles:** -- Media manager first, download clients second -- Everything optional except the core media library -- First-boot wizard configures enabled modules -- Disabled modules are hidden from UI entirely -- PostgreSQL database (avoids SQLite contention) -- Single binary, single container + optional Indexarr sidecar - ---- - -## 1. Cargo Workspace Structure - -``` -arz/ -├── Cargo.toml # Workspace root -├── crates/ -│ ├── arz-core/ # Domain models, DB, config, error types -│ ├── arz-media/ # Media library: series, movies, episodes, files -│ ├── arz-parser/ # Release name parser (quality, episode, language) -│ ├── arz-quality/ # Quality profiles, custom formats, decision engine -│ ├── arz-indexer/ # Indexer hub: Newznab/Torznab client, Indexarr integration -│ ├── arz-download/ # Download client abstraction layer -│ ├── arz-import/ # Post-download import: match, rename, move, organize -│ ├── arz-scheduler/ # Background jobs: RSS, search, import scan, housekeeping -│ ├── arz-metadata/ # External metadata: TMDB, TVDB, OMDB clients -│ ├── arz-web/ # Axum HTTP server, REST API, WebSocket, auth -│ └── arz-notify/ # Notifications: webhook, Telegram, Discord, email, etc. -├── src/ -│ └── main.rs # Binary: CLI args, startup, signal handling -├── ui/ # React frontend (Vite + TypeScript) -├── migrations/ # PostgreSQL migrations (sqlx) -├── docker/ -│ ├── Dockerfile -│ └── docker-compose.yml # arz + optional indexarr sidecar -└── config.example.toml -``` - -### Crate dependency graph - -``` -main.rs - └── arz-web - ├── arz-media - │ ├── arz-core - │ ├── arz-parser - │ └── arz-metadata - ├── arz-quality - │ ├── arz-core - │ └── arz-parser - ├── arz-indexer - │ ├── arz-core - │ └── arz-parser - ├── arz-download - │ └── arz-core - ├── arz-import - │ ├── arz-core - │ ├── arz-media - │ ├── arz-parser - │ └── arz-quality - ├── arz-scheduler - │ ├── arz-core - │ ├── arz-media - │ ├── arz-indexer - │ ├── arz-download - │ ├── arz-import - │ └── arz-quality - └── arz-notify - └── arz-core -``` - -### Feature flags (Cargo features on workspace) - -```toml -[features] -default = ["ui"] -ui = [] # Embed React SPA -torrent-embedded = ["librtbit"] # Embedded rustTorrent engine -usenet-embedded = ["nzb-core", "nzb-web", "nzb-nntp", "nzb-decode", "nzb-postproc"] -indexarr-sidecar = [] # Indexarr HTTP client integration -``` - ---- - -## 2. Domain Model - -### 2.1 Media Library (arz-media) - -```rust -// --- TV --- -pub struct Series { - pub id: i64, - pub title: String, - pub clean_title: String, // normalized for matching - pub sort_title: String, - pub overview: Option, - pub status: SeriesStatus, // Continuing, Ended, Upcoming, Deleted - pub network: Option, - pub air_time: Option, - pub first_aired: Option, - pub year: Option, - pub runtime: Option, // minutes - pub path: PathBuf, // library root folder for this series - pub root_folder_id: i64, - pub quality_profile_id: i64, - pub season_folder: bool, - pub monitored: bool, - pub use_scene_numbering: bool, - pub series_type: SeriesType, // Standard, Daily, Anime - // External IDs - pub tvdb_id: Option, - pub imdb_id: Option, - pub tmdb_id: Option, - pub tvmaze_id: Option, - pub mal_id: Option, - pub tags: Vec, - pub added_at: DateTime, - pub last_info_sync: Option>, -} - -pub struct Episode { - pub id: i64, - pub series_id: i64, - pub episode_file_id: Option, - pub season_number: i32, - pub episode_number: i32, - pub absolute_number: Option, - pub scene_season_number: Option, - pub scene_episode_number: Option, - pub scene_absolute_number: Option, - pub title: Option, - pub overview: Option, - pub air_date: Option, - pub air_date_utc: Option>, - pub runtime: Option, - pub monitored: bool, - pub grabbed: bool, // currently being downloaded - pub last_search_time: Option>, -} - -// --- Movies --- -pub struct Movie { - pub id: i64, - pub title: String, - pub clean_title: String, - pub sort_title: String, - pub overview: Option, - pub year: Option, - pub studio: Option, - pub path: PathBuf, - pub root_folder_id: i64, - pub quality_profile_id: i64, - pub monitored: bool, - pub minimum_availability: Availability, // Announced, InCinemas, Released - pub movie_file_id: Option, - // External IDs - pub tmdb_id: Option, - pub imdb_id: Option, - // Dates - pub in_cinemas: Option, - pub physical_release: Option, - pub digital_release: Option, - pub tags: Vec, - pub added_at: DateTime, - pub last_info_sync: Option>, - pub collection_tmdb_id: Option, -} - -// --- Shared --- -pub struct MediaFile { - pub id: i64, - pub media_type: MediaType, // Series, Movie - pub relative_path: String, - pub size: i64, - pub date_added: DateTime, - pub quality: QualityModel, - pub languages: Vec, - pub scene_name: Option, - pub release_group: Option, - pub release_hash: Option, - pub edition: Option, // movies: Director's Cut, etc. - pub media_info: Option, - pub indexer_flags: i32, -} - -pub enum MediaType { Series, Movie } -pub enum SeriesStatus { Continuing, Ended, Upcoming, Deleted } -pub enum SeriesType { Standard, Daily, Anime } -pub enum Availability { Announced, InCinemas, Released } -``` - -### 2.2 Parser (arz-parser) - -```rust -pub struct ParsedRelease { - pub title: String, - pub release_title: String, // original full name - pub year: Option, - pub quality: QualityModel, - pub languages: Vec, - pub release_group: Option, - pub release_hash: Option, - pub edition: Option, - // TV-specific (Option — absent for movies) - pub season_number: Option, - pub episode_numbers: Vec, - pub absolute_episode_numbers: Vec, - pub air_date: Option, // daily shows - pub is_full_season: bool, - pub is_multi_season: bool, - pub is_special: bool, - // Movie-specific - pub imdb_id: Option, - pub tmdb_id: Option, -} - -pub struct QualityModel { - pub quality: Quality, - pub revision: Revision, - pub source: QualitySource, // where detected from -} - -pub enum Quality { - Unknown, - SDTV, DVD, WEBDL480p, WEBRip480p, Bluray480p, - HDTV720p, WEBDL720p, WEBRip720p, Bluray720p, - HDTV1080p, WEBDL1080p, WEBRip1080p, Bluray1080p, Remux1080p, - HDTV2160p, WEBDL2160p, WEBRip2160p, Bluray2160p, Remux2160p, - Raw, -} - -pub struct Revision { - pub version: i32, // 1 = original, 2+ = proper/repack - pub real: i32, // 0 = normal, 1+ = REAL tag - pub is_repack: bool, -} -``` - -### 2.3 Quality Profiles (arz-quality) - -```rust -pub struct QualityProfile { - pub id: i64, - pub name: String, - pub cutoff: Quality, // stop upgrading at this level - pub items: Vec, - pub upgrade_allowed: bool, - pub min_format_score: i32, - pub cutoff_format_score: i32, -} - -pub struct QualityProfileItem { - pub quality: Option, // None = group header - pub allowed: bool, - pub items: Vec, // nested groups -} - -pub struct CustomFormat { - pub id: i64, - pub name: String, - pub specifications: Vec, -} - -pub struct FormatSpecification { - pub field: FormatField, // ReleaseName, Quality, Language, IndexerFlag, etc. - pub pattern: String, // regex or enum match - pub negate: bool, - pub required: bool, -} - -pub struct CustomFormatScore { - pub profile_id: i64, - pub format_id: i64, - pub score: i32, -} -``` - -### 2.4 Decision Engine (arz-quality) - -```rust -pub struct DownloadDecision { - pub remote_release: RemoteRelease, - pub rejections: Vec, -} - -pub struct Rejection { - pub reason: String, - pub rejection_type: RejectionType, // Permanent, Temporary -} - -/// Each spec checks one concern. Run in order, short-circuit on permanent reject. -pub trait DecisionSpecification: Send + Sync { - fn is_satisfied(&self, decision: &DecisionContext) -> SpecificationResult; -} - -// Specifications: -// - QualityAllowedSpec -// - QualityCutoffSpec (already have good enough?) -// - SizeLimitSpec (min/max per quality) -// - AgeLimitSpec (usenet retention) -// - BlocklistSpec -// - AlreadyImportedSpec -// - QueueConflictSpec (already downloading?) -// - CustomFormatScoreSpec -// - ProtocolSpec (usenet vs torrent preference) -// - MinimumSeedersSpec (torrents) -// - RepackSpec (prefer repacks of same release) -// - RawDiskSpec (reject raw disk images) -// - SampleSpec (reject samples) -// - LanguageSpec -``` - -### 2.5 Indexer Hub (arz-indexer) - -```rust -pub struct IndexerConfig { - pub id: i64, - pub name: String, - pub indexer_type: IndexerType, - pub base_url: String, - pub api_key: Option, - pub protocol: DownloadProtocol, // Usenet, Torrent - pub categories: Vec, // Newznab category IDs - pub enabled: bool, - pub priority: i32, - pub supports_search: bool, - pub supports_rss: bool, - pub proxy: Option, -} - -pub enum IndexerType { - Newznab, // Standard Newznab (NZB indexers) - Torznab, // Standard Torznab (torrent indexers) - IndexarrTorznab, // Indexarr sidecar via Torznab - IndexarrApi, // Indexarr sidecar via REST API -} - -pub struct ReleaseInfo { - pub guid: String, - pub title: String, - pub download_url: Option, - pub info_url: Option, - pub indexer_id: i64, - pub protocol: DownloadProtocol, - pub size: i64, - pub age: i64, // days - pub publish_date: DateTime, - // Torrent-specific - pub info_hash: Option, - pub magnet_url: Option, - pub seeders: Option, - pub leechers: Option, - // Usenet-specific - pub nzb_url: Option, - // IDs from indexer - pub tvdb_id: Option, - pub imdb_id: Option, - pub tmdb_id: Option, - // Parsed - pub categories: Vec, // Newznab category IDs - pub indexer_flags: Vec, -} - -pub enum DownloadProtocol { Usenet, Torrent } -``` - -### 2.6 Download Client Abstraction (arz-download) - -```rust -/// Unified interface — works for embedded AND external clients -#[async_trait] -pub trait DownloadClient: Send + Sync { - fn protocol(&self) -> DownloadProtocol; - fn name(&self) -> &str; - - async fn add(&self, release: &GrabRequest) -> Result; // returns download_id - async fn get_items(&self) -> Result>; - async fn remove(&self, download_id: &str, delete_files: bool) -> Result<()>; - async fn pause(&self, download_id: &str) -> Result<()>; - async fn resume(&self, download_id: &str) -> Result<()>; - async fn test(&self) -> Result<()>; - async fn status(&self) -> Result; -} - -pub struct GrabRequest { - pub release: ReleaseInfo, - pub category: String, - pub download_url: String, // NZB URL or magnet/torrent URL -} - -pub struct DownloadItem { - pub download_id: String, - pub title: String, - pub status: DownloadStatus, - pub total_size: i64, - pub remaining_size: i64, - pub output_path: Option, - pub category: Option, - pub can_move_files: bool, - pub can_be_removed: bool, - pub protocol: DownloadProtocol, -} - -pub enum DownloadStatus { - Queued, Downloading, Paused, - PostProcessing, // usenet: par2/extract - Completed, Failed, Warning, -} - -// --- Implementations --- - -// External clients (HTTP API): -pub struct QBittorrentClient { /* qBit WebUI API */ } -pub struct TransmissionClient { /* Transmission RPC */ } -pub struct SabnzbdClient { /* SABnzbd API */ } -pub struct NzbgetClient { /* NZBGet API */ } - -// Embedded clients (library calls): -#[cfg(feature = "torrent-embedded")] -pub struct EmbeddedTorrentClient { - session: Arc, -} - -#[cfg(feature = "usenet-embedded")] -pub struct EmbeddedUsenetClient { - queue_manager: Arc, -} -``` - -### 2.7 History & Queue (arz-core) - -```rust -pub struct HistoryEvent { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, // series_id or movie_id - pub episode_id: Option, // TV only - pub event_type: HistoryEventType, - pub quality: QualityModel, - pub languages: Vec, - pub source_title: String, // release name - pub download_id: Option, - pub indexer_id: Option, - pub download_client: Option, - pub data: serde_json::Value, // event-specific metadata - pub occurred_at: DateTime, -} - -pub enum HistoryEventType { - Grabbed, Imported, DownloadFailed, - FileDeleted, FileRenamed, DownloadIgnored, -} - -pub struct QueueItem { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, - pub episode_id: Option, - pub quality: QualityModel, - pub languages: Vec, - pub size: i64, - pub title: String, - pub status: DownloadStatus, - pub time_left: Option, - pub download_id: String, - pub download_client: String, - pub protocol: DownloadProtocol, - pub indexer: String, - pub error_message: Option, - pub added_at: DateTime, -} - -pub struct Blocklist { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, - pub source_title: String, - pub quality: QualityModel, - pub languages: Vec, - pub indexer_id: Option, - pub info_hash: Option, // torrent matching - pub message: Option, - pub added_at: DateTime, -} -``` - -### 2.8 Modules/Features Configuration (arz-core) - -```rust -/// Persisted in DB — set during first-boot, changeable in settings -pub struct EnabledModules { - pub tv_management: bool, // Series/Episodes - pub movie_management: bool, // Movies - pub torrent_embedded: bool, // Embedded rustTorrent - pub usenet_embedded: bool, // Embedded rustnzbd - pub torrent_external: bool, // External torrent clients - pub usenet_external: bool, // External usenet clients - pub indexarr_sidecar: bool, // Indexarr integration - pub external_indexers: bool, // Newznab/Torznab indexers - pub notifications: bool, -} -``` - ---- - -## 3. Database Schema (PostgreSQL) - -```sql --- Core -CREATE TABLE app_config ( - key TEXT PRIMARY KEY, - value JSONB NOT NULL -); - -CREATE TABLE enabled_modules ( - id SERIAL PRIMARY KEY, - module TEXT UNIQUE NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT false, - config JSONB -); - -CREATE TABLE root_folders ( - id SERIAL PRIMARY KEY, - path TEXT NOT NULL UNIQUE, - media_type TEXT NOT NULL, -- 'series' | 'movie' - free_space BIGINT, - last_checked TIMESTAMPTZ -); - -CREATE TABLE tags ( - id SERIAL PRIMARY KEY, - label TEXT NOT NULL UNIQUE -); - --- Quality system -CREATE TABLE quality_profiles ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - cutoff INTEGER NOT NULL, - upgrade_allowed BOOLEAN NOT NULL DEFAULT true, - min_format_score INTEGER NOT NULL DEFAULT 0, - cutoff_format_score INTEGER NOT NULL DEFAULT 0, - items JSONB NOT NULL -- ordered quality items tree -); - -CREATE TABLE custom_formats ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - specifications JSONB NOT NULL -); - -CREATE TABLE custom_format_scores ( - profile_id INTEGER REFERENCES quality_profiles(id) ON DELETE CASCADE, - format_id INTEGER REFERENCES custom_formats(id) ON DELETE CASCADE, - score INTEGER NOT NULL, - PRIMARY KEY (profile_id, format_id) -); - --- TV -CREATE TABLE series ( - id SERIAL PRIMARY KEY, - title TEXT NOT NULL, - clean_title TEXT NOT NULL, - sort_title TEXT NOT NULL, - overview TEXT, - status TEXT NOT NULL DEFAULT 'continuing', - series_type TEXT NOT NULL DEFAULT 'standard', - network TEXT, - air_time TIME, - first_aired DATE, - year INTEGER, - runtime INTEGER, - path TEXT NOT NULL, - root_folder_id INTEGER REFERENCES root_folders(id), - quality_profile_id INTEGER REFERENCES quality_profiles(id), - season_folder BOOLEAN NOT NULL DEFAULT true, - monitored BOOLEAN NOT NULL DEFAULT true, - use_scene_numbering BOOLEAN NOT NULL DEFAULT false, - tvdb_id INTEGER, - imdb_id TEXT, - tmdb_id INTEGER, - tvmaze_id INTEGER, - mal_id INTEGER, - images JSONB, - genres TEXT[], - tags INTEGER[], - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_info_sync TIMESTAMPTZ -); -CREATE INDEX idx_series_tvdb ON series(tvdb_id); -CREATE INDEX idx_series_tmdb ON series(tmdb_id); -CREATE INDEX idx_series_imdb ON series(imdb_id); -CREATE INDEX idx_series_clean_title ON series(clean_title); - -CREATE TABLE seasons ( - id SERIAL PRIMARY KEY, - series_id INTEGER NOT NULL REFERENCES series(id) ON DELETE CASCADE, - season_number INTEGER NOT NULL, - monitored BOOLEAN NOT NULL DEFAULT true, - UNIQUE(series_id, season_number) -); - -CREATE TABLE episodes ( - id SERIAL PRIMARY KEY, - series_id INTEGER NOT NULL REFERENCES series(id) ON DELETE CASCADE, - season_number INTEGER NOT NULL, - episode_number INTEGER NOT NULL, - absolute_number INTEGER, - scene_season_number INTEGER, - scene_episode_number INTEGER, - scene_absolute_number INTEGER, - title TEXT, - overview TEXT, - air_date DATE, - air_date_utc TIMESTAMPTZ, - runtime INTEGER, - monitored BOOLEAN NOT NULL DEFAULT true, - episode_file_id INTEGER REFERENCES media_files(id) ON DELETE SET NULL, - last_search_time TIMESTAMPTZ, - UNIQUE(series_id, season_number, episode_number) -); -CREATE INDEX idx_episodes_air_date ON episodes(air_date_utc); - --- Movies -CREATE TABLE movies ( - id SERIAL PRIMARY KEY, - title TEXT NOT NULL, - clean_title TEXT NOT NULL, - sort_title TEXT NOT NULL, - overview TEXT, - year INTEGER, - studio TEXT, - path TEXT NOT NULL, - root_folder_id INTEGER REFERENCES root_folders(id), - quality_profile_id INTEGER REFERENCES quality_profiles(id), - monitored BOOLEAN NOT NULL DEFAULT true, - minimum_availability TEXT NOT NULL DEFAULT 'released', - movie_file_id INTEGER REFERENCES media_files(id) ON DELETE SET NULL, - tmdb_id INTEGER, - imdb_id TEXT, - in_cinemas DATE, - physical_release DATE, - digital_release DATE, - images JSONB, - genres TEXT[], - tags INTEGER[], - collection_tmdb_id INTEGER, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_info_sync TIMESTAMPTZ -); -CREATE INDEX idx_movies_tmdb ON movies(tmdb_id); -CREATE INDEX idx_movies_imdb ON movies(imdb_id); -CREATE INDEX idx_movies_clean_title ON movies(clean_title); - -CREATE TABLE alternative_titles ( - id SERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id INTEGER NOT NULL, - title TEXT NOT NULL, - clean_title TEXT NOT NULL, - scene_name BOOLEAN NOT NULL DEFAULT false -); -CREATE INDEX idx_alt_titles_clean ON alternative_titles(clean_title); - --- Media files (shared between TV and movies) -CREATE TABLE media_files ( - id SERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - relative_path TEXT NOT NULL, - size BIGINT NOT NULL, - date_added TIMESTAMPTZ NOT NULL DEFAULT NOW(), - quality JSONB NOT NULL, - languages JSONB NOT NULL, - scene_name TEXT, - release_group TEXT, - release_hash TEXT, - edition TEXT, - media_info JSONB, - indexer_flags INTEGER NOT NULL DEFAULT 0 -); - --- Episode-to-file join (multi-episode files) -CREATE TABLE episode_files ( - episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, - media_file_id INTEGER NOT NULL REFERENCES media_files(id) ON DELETE CASCADE, - PRIMARY KEY (episode_id, media_file_id) -); - --- Indexers -CREATE TABLE indexers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - indexer_type TEXT NOT NULL, - base_url TEXT NOT NULL, - api_key TEXT, - protocol TEXT NOT NULL, - categories INTEGER[], - enabled BOOLEAN NOT NULL DEFAULT true, - priority INTEGER NOT NULL DEFAULT 25, - supports_search BOOLEAN NOT NULL DEFAULT true, - supports_rss BOOLEAN NOT NULL DEFAULT true, - config JSONB, -- proxy, extra settings - last_rss_sync TIMESTAMPTZ -); - --- Download clients -CREATE TABLE download_clients ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - client_type TEXT NOT NULL, -- 'embedded_torrent', 'embedded_usenet', 'qbittorrent', 'sabnzbd', etc. - protocol TEXT NOT NULL, - config JSONB NOT NULL, -- host, port, api_key, category mappings - enabled BOOLEAN NOT NULL DEFAULT true, - priority INTEGER NOT NULL DEFAULT 1 -); - --- Queue (tracked downloads in progress) -CREATE TABLE queue ( - id SERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id INTEGER NOT NULL, - episode_id INTEGER, - title TEXT NOT NULL, - quality JSONB NOT NULL, - languages JSONB, - size BIGINT, - status TEXT NOT NULL, - download_id TEXT NOT NULL, - download_client_id INTEGER REFERENCES download_clients(id), - indexer_id INTEGER REFERENCES indexers(id), - protocol TEXT NOT NULL, - error_message TEXT, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_queue_download_id ON queue(download_id); - --- History -CREATE TABLE history ( - id BIGSERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id INTEGER NOT NULL, - episode_id INTEGER, - event_type TEXT NOT NULL, - quality JSONB NOT NULL, - languages JSONB, - source_title TEXT NOT NULL, - download_id TEXT, - indexer_id INTEGER, - download_client TEXT, - data JSONB, - occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_history_media ON history(media_type, media_id); -CREATE INDEX idx_history_occurred ON history(occurred_at DESC); -CREATE INDEX idx_history_download_id ON history(download_id); - --- Blocklist -CREATE TABLE blocklist ( - id SERIAL PRIMARY KEY, - media_type TEXT NOT NULL, - media_id INTEGER NOT NULL, - source_title TEXT NOT NULL, - quality JSONB NOT NULL, - languages JSONB, - indexer_id INTEGER, - info_hash TEXT, - message TEXT, - added_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_blocklist_media ON blocklist(media_type, media_id); -CREATE INDEX idx_blocklist_hash ON blocklist(info_hash); - --- Naming conventions -CREATE TABLE naming_config ( - id SERIAL PRIMARY KEY, - media_type TEXT NOT NULL UNIQUE, - rename_files BOOLEAN NOT NULL DEFAULT true, - standard_format TEXT, -- token pattern: {Series Title} - S{season:00}E{episode:00} - daily_format TEXT, - anime_format TEXT, - season_folder_format TEXT, - movie_format TEXT, - movie_folder_format TEXT, - colon_replacement TEXT NOT NULL DEFAULT 'smart' -); - --- Notifications -CREATE TABLE notification_providers ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - provider_type TEXT NOT NULL, -- 'webhook', 'telegram', 'discord', etc. - config JSONB NOT NULL, - on_grab BOOLEAN NOT NULL DEFAULT false, - on_import BOOLEAN NOT NULL DEFAULT false, - on_upgrade BOOLEAN NOT NULL DEFAULT false, - on_health_issue BOOLEAN NOT NULL DEFAULT false, - on_failure BOOLEAN NOT NULL DEFAULT false, - enabled BOOLEAN NOT NULL DEFAULT true -); - --- Import lists (external sources of media to add) -CREATE TABLE import_lists ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - list_type TEXT NOT NULL, -- 'tmdb_popular', 'trakt_watchlist', 'imdb_list', etc. - media_type TEXT NOT NULL, - config JSONB NOT NULL, - quality_profile_id INTEGER REFERENCES quality_profiles(id), - root_folder_id INTEGER REFERENCES root_folders(id), - monitored BOOLEAN NOT NULL DEFAULT true, - enabled BOOLEAN NOT NULL DEFAULT true, - poll_interval_secs INTEGER NOT NULL DEFAULT 3600 -); -``` - ---- - -## 4. REST API Design - -``` -# First-boot & system -GET /api/v1/system/status # Version, uptime, enabled modules -GET /api/v1/system/health # Health checks -POST /api/v1/setup/init # First-boot: set modules + basic config -PUT /api/v1/setup/modules # Enable/disable modules (restart required for embedded clients) - -# Series (hidden if tv_management disabled) -GET /api/v1/series # List all -POST /api/v1/series # Add series (lookup + add) -GET /api/v1/series/{id} -PUT /api/v1/series/{id} -DELETE /api/v1/series/{id} -GET /api/v1/series/lookup?term= # Search TVDB/TMDB -GET /api/v1/series/{id}/episodes -GET /api/v1/episode/{id} -PUT /api/v1/episode/{id} # Toggle monitored, etc. - -# Movies (hidden if movie_management disabled) -GET /api/v1/movie # List all -POST /api/v1/movie # Add movie -GET /api/v1/movie/{id} -PUT /api/v1/movie/{id} -DELETE /api/v1/movie/{id} -GET /api/v1/movie/lookup?term= # Search TMDB - -# Calendar -GET /api/v1/calendar?start=&end= # Upcoming episodes + movie releases - -# Wanted -GET /api/v1/wanted/missing # Monitored, no file -GET /api/v1/wanted/cutoff # Have file, below cutoff quality - -# Media files -GET /api/v1/mediafile # All files -GET /api/v1/mediafile/{id} -DELETE /api/v1/mediafile/{id} -PUT /api/v1/rename # Preview rename -POST /api/v1/rename # Execute rename - -# Manual import -GET /api/v1/manualimport?folder= # Scan folder, return matches -POST /api/v1/manualimport # Execute import of selected files - -# Quality profiles -GET /api/v1/qualityprofile -POST /api/v1/qualityprofile -PUT /api/v1/qualityprofile/{id} -DELETE /api/v1/qualityprofile/{id} - -# Custom formats -GET /api/v1/customformat -POST /api/v1/customformat -PUT /api/v1/customformat/{id} -DELETE /api/v1/customformat/{id} - -# Indexers -GET /api/v1/indexer -POST /api/v1/indexer -PUT /api/v1/indexer/{id} -DELETE /api/v1/indexer/{id} -POST /api/v1/indexer/{id}/test - -# Download clients -GET /api/v1/downloadclient -POST /api/v1/downloadclient -PUT /api/v1/downloadclient/{id} -DELETE /api/v1/downloadclient/{id} -POST /api/v1/downloadclient/{id}/test - -# Releases (search results) -GET /api/v1/release?episodeId= # Search indexers for episode -GET /api/v1/release?movieId= # Search indexers for movie -POST /api/v1/release # Grab a release (send to download client) -POST /api/v1/release/push # External push (webhook from indexer) - -# Queue -GET /api/v1/queue # Current downloads -DELETE /api/v1/queue/{id} # Remove + optional blocklist -POST /api/v1/queue/grab/{id} # Force grab pending item - -# History -GET /api/v1/history # Paginated event log -POST /api/v1/history/failed/{id} # Mark as failed → blocklist + re-search - -# Blocklist -GET /api/v1/blocklist -DELETE /api/v1/blocklist/{id} -DELETE /api/v1/blocklist/bulk - -# Commands (async job triggers) -POST /api/v1/command # { name: "SeriesSearch", seriesId: 1 } -GET /api/v1/command/{id} # Check command status - -# Supported commands: -# SeriesSearch, SeasonSearch, EpisodeSearch -# MovieSearch -# RssSync -# RefreshSeries, RefreshMovie -# DiskScan (rescan library) -# MissingSearch, CutoffSearch -# Housekeeping - -# Config -GET /api/v1/config/naming -PUT /api/v1/config/naming -GET /api/v1/config/general -PUT /api/v1/config/general - -# Embedded torrent client status (hidden if not enabled) -GET /api/v1/torrent/status # rustTorrent session stats -GET /api/v1/torrent/list # Active torrents - -# Embedded usenet client status (hidden if not enabled) -GET /api/v1/usenet/status # rustnzbd queue stats -GET /api/v1/usenet/servers # NNTP server config - -# Notifications -GET /api/v1/notification -POST /api/v1/notification -PUT /api/v1/notification/{id} -DELETE /api/v1/notification/{id} -POST /api/v1/notification/{id}/test - -# Tags -GET /api/v1/tag -POST /api/v1/tag -PUT /api/v1/tag/{id} -DELETE /api/v1/tag/{id} - -# Root folders -GET /api/v1/rootfolder -POST /api/v1/rootfolder -DELETE /api/v1/rootfolder/{id} - -# Import lists -GET /api/v1/importlist -POST /api/v1/importlist -PUT /api/v1/importlist/{id} -DELETE /api/v1/importlist/{id} - -# Log -GET /api/v1/log # Paginated log entries -WS /api/v1/log/stream # Real-time log WebSocket - -# Indexarr sidecar (hidden if not enabled) -GET /api/v1/indexarr/status # Sidecar health + stats -GET /api/v1/indexarr/search # Proxy search through sidecar -``` - ---- - -## 5. Integration Architecture - -### 5.1 Embedded rustTorrent - -```rust -#[cfg(feature = "torrent-embedded")] -pub struct EmbeddedTorrentClient { - session: Arc, - api: librtbit::Api, -} - -impl EmbeddedTorrentClient { - pub async fn new(config: &TorrentConfig) -> Result { - let opts = SessionOptions { - persistence: Some(SessionPersistenceConfig::Json { - folder: Some(config.data_dir.join("torrent-state")), - }), - listen: Some(ListenerOptions { /* ... */ }), - ..Default::default() - }; - let session = Session::new_with_opts(config.download_dir.clone(), opts).await?; - let api = Api::new(Arc::clone(&session), None); - Ok(Self { session, api }) - } -} - -#[async_trait] -impl DownloadClient for EmbeddedTorrentClient { - async fn add(&self, req: &GrabRequest) -> Result { - let opts = AddTorrentOptions { - category: Some(req.category.clone()), - ..Default::default() - }; - let resp = self.session.add_torrent( - AddTorrent::from_url(&req.download_url), - Some(opts), - ).await?; - Ok(resp.into_handle().unwrap().info_hash().to_string()) - } - - async fn get_items(&self) -> Result> { - let list = self.api.api_torrent_list(); - // Map TorrentListResponse → Vec - } - // ... -} -``` - -### 5.2 Embedded rustnzbd - -```rust -#[cfg(feature = "usenet-embedded")] -pub struct EmbeddedUsenetClient { - queue_manager: Arc, -} - -impl EmbeddedUsenetClient { - pub async fn new(config: &UsenetConfig) -> Result { - let startup = nzb_web::startup::initialize( - StartupConfig { - config_path: config.config_path.clone(), - data_dir: Some(config.data_dir.clone()), - ..Default::default() - }, - None, - ).await?; - Ok(Self { queue_manager: startup.queue_manager }) - } -} - -#[async_trait] -impl DownloadClient for EmbeddedUsenetClient { - async fn add(&self, req: &GrabRequest) -> Result { - let nzb_data = reqwest::get(&req.download_url).await?.bytes().await?; - let mut job = nzb_core::nzb_parser::parse_nzb(&req.release.title, &nzb_data)?; - job.category = req.category.clone(); - self.queue_manager.add_job(job.clone(), Some(nzb_data.to_vec())).await?; - Ok(job.id) - } - - async fn get_items(&self) -> Result> { - let jobs = self.queue_manager.get_jobs(); - // Map NzbJob → DownloadItem - } - // ... -} -``` - -### 5.3 External clients - -```rust -// qBittorrent WebUI API -pub struct QBittorrentClient { base_url: String, session: reqwest::Client } -// SABnzbd API -pub struct SabnzbdClient { base_url: String, api_key: String, session: reqwest::Client } -// NZBGet JSON-RPC -pub struct NzbgetClient { base_url: String, session: reqwest::Client } -``` - -### 5.4 Indexarr Sidecar - -```rust -pub struct IndexarrClient { - base_url: String, - api_key: String, - client: reqwest::Client, -} - -impl IndexarrClient { - /// Use Torznab API (Sonarr/Radarr compatible) - pub async fn torznab_search(&self, params: &TorznabQuery) -> Result> { - // GET {base_url}/api/torznab?t=search&q=...&apikey=... - } - - /// Use native REST API for richer results - pub async fn search(&self, query: &str, filters: &SearchFilters) -> Result> { - // GET {base_url}/api/v1/search?q=... - } - - pub async fn status(&self) -> Result { - // GET {base_url}/api/v1/stats - } -} -``` - ---- - -## 6. Search Flow - -``` -User triggers search (manual or scheduled) - │ - ▼ -SearchService - ├── Build search criteria from media (Series+Episode or Movie) - │ - Extract IDs: tvdb_id, imdb_id, tmdb_id - │ - Build query terms: "Show Name S01E05" / "Movie Name 2024" - │ - Determine categories: TV HD (5040) / Movies HD (2040) etc. - │ - ├── Fan out to all enabled indexers (parallel) - │ ├── Newznab indexers → GET /api?t=tvsearch&tvdbid=&season=&ep= - │ ├── Torznab indexers → GET /api?t=tvsearch&tvdbid=&season=&ep= - │ ├── Indexarr (Torznab) → GET /api/torznab?t=tvsearch&... - │ └── Indexarr (REST) → GET /api/v1/search?q=... - │ - ├── Aggregate all ReleaseInfo results - │ - ├── Parse each release name → ParsedRelease - │ - Extract quality, language, release group - │ - Match to correct media (by title similarity + IDs) - │ - ├── Run Decision Engine on each release - │ - Check against quality profile - │ - Filter by size limits - │ - Check blocklist - │ - Check if already in queue - │ - Check if already imported at same/better quality - │ - Score custom formats - │ - Reject or approve - │ - ├── Sort approved releases by preference - │ - Quality rank (from profile) - │ - Custom format score - │ - Protocol preference (usenet vs torrent) - │ - Indexer priority - │ - Age (prefer newer for usenet) - │ - Seeders (prefer more for torrent) - │ - └── Grab best release (or return ranked list for interactive search) - ├── Select download client (by protocol + priority) - ├── client.add(GrabRequest) → download_id - ├── Insert queue record - ├── Insert history record (Grabbed) - └── Send notification (on_grab) -``` - ---- - -## 7. Download → Import Flow - -``` -Background: CompletedDownloadService (polls every 60s) - │ - ├── For each enabled download client: - │ └── client.get_items() → Vec - │ - ├── Match DownloadItem.download_id to queue records - │ - ├── For completed items: - │ │ - │ ▼ - │ ImportService - │ ├── Scan output_path for media files - │ ├── Parse each filename → ParsedRelease - │ ├── Match to Series+Episode or Movie (using queue record as hint) - │ ├── Run import decision engine (quality upgrade check) - │ │ - │ ├── For each approved file: - │ │ ├── Build target path using naming config tokens - │ │ │ TV: {root}/{Series}/{Season 01}/{Series - S01E05 - Title [Quality]} - │ │ │ Movie: {root}/{Movie (Year)}/{Movie (Year) - [Quality]} - │ │ ├── Move/hardlink/copy file to library - │ │ ├── Create/update MediaFile record - │ │ ├── Link to Episode or Movie - │ │ ├── Delete old file if upgrade - │ │ ├── Insert history record (Imported) - │ │ └── Send notification (on_import) - │ │ - │ └── Remove queue record - │ - └── For failed items: - ├── Insert history record (DownloadFailed) - ├── Add to blocklist (optional, based on settings) - ├── Remove from download client - ├── Remove queue record - ├── Trigger re-search if configured - └── Send notification (on_failure) -``` - ---- - -## 8. Configuration - -```toml -# config.toml - -[general] -instance_name = "Arz" -bind_addr = "0.0.0.0" -port = 8989 -data_dir = "/config" -log_level = "info" - -[database] -url = "postgresql://arz:password@localhost:5432/arz" -max_connections = 20 - -[auth] -method = "forms" # none, forms, basic, external -api_key = "auto-generated" - -# --- Optional embedded modules --- - -[torrent] -enabled = false # set true via first-boot -download_dir = "/downloads/torrents/incomplete" -complete_dir = "/downloads/torrents/complete" -listen_port = 6881 -dht_enabled = true -peer_limit = 200 -upload_limit_bps = 0 -download_limit_bps = 0 - -[usenet] -enabled = false # set true via first-boot -incomplete_dir = "/downloads/usenet/incomplete" -complete_dir = "/downloads/usenet/complete" -max_active_downloads = 3 - -[[usenet.servers]] -name = "Primary" -host = "news.example.com" -port = 563 -ssl = true -username = "" -password = "" -connections = 8 -priority = 0 - -[indexarr] -enabled = false # set true via first-boot -url = "http://indexarr:8080" -api_key = "" -mode = "peer" # peer (sync only), full (DHT + sync) - -[naming.series] -rename = true -standard = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]" -daily = "{Series Title} - {Air-Date} - {Episode Title} [{Quality Title}]" -anime = "{Series Title} - S{season:00}E{episode:00} - {Absolute Episode} - {Episode Title} [{Quality Title}]" -season_folder = "Season {season:00}" - -[naming.movie] -rename = true -standard = "{Movie Title} ({Release Year}) [{Quality Title}]{[Edition Tags]}" -folder = "{Movie Title} ({Release Year})" -``` - ---- - -## 9. Background Services (arz-scheduler) - -All run as tokio tasks inside the single process: - -| Service | Interval | Purpose | -|---------|----------|---------| -| **RssSyncService** | 15 min | Poll all indexers' RSS feeds, auto-grab matching releases | -| **CompletedDownloadService** | 60s | Poll download clients, trigger import for completed items | -| **RefreshSeriesService** | 12 hr | Refresh metadata from TVDB/TMDB for all series | -| **RefreshMovieService** | 12 hr | Refresh metadata from TMDB for all movies | -| **DiskScanService** | on-demand | Scan library folders, detect new/removed files | -| **HousekeepingService** | 24 hr | Clean old history, expired blocklist entries, orphaned records | -| **ImportListSyncService** | 1 hr | Poll import lists, auto-add new media | -| **HealthCheckService** | 5 min | Check download clients, indexers, disk space | -| **QueueCleanupService** | 15 min | Detect stale queue items (download client no longer has them) | -| **SearchScheduler** | configurable | Scheduled searches for missing/cutoff media | -| **EmbeddedTorrentMonitor** | 30s | (if enabled) Track rustTorrent session health | -| **EmbeddedUsenetMonitor** | 30s | (if enabled) Track rustnzbd queue health | -| **IndexarrHealthCheck** | 5 min | (if enabled) Check sidecar connectivity | - ---- - -## 10. First-Boot Flow - -``` -1. User opens http://localhost:8989 for first time -2. App detects no config in DB → serves first-boot wizard - -Wizard steps: - ┌─────────────────────────────────────────────┐ - │ Step 1: Welcome │ - │ "What do you want to manage?" │ - │ [x] TV Series │ - │ [x] Movies │ - │ [ ] (future: Music, Books) │ - ├─────────────────────────────────────────────┤ - │ Step 2: Download Clients │ - │ Torrent: │ - │ ( ) None │ - │ ( ) Built-in (rustTorrent) │ - │ ( ) External (qBittorrent/Transmission) │ - │ │ - │ Usenet: │ - │ ( ) None │ - │ ( ) Built-in (rustnzbd) │ - │ ( ) External (SABnzbd/NZBGet) │ - │ │ - │ [If external selected → config fields] │ - ├─────────────────────────────────────────────┤ - │ Step 3: Indexers │ - │ ( ) None (add later) │ - │ ( ) Built-in Indexarr sidecar │ - │ └── [auto-detect http://indexarr:8080] │ - │ ( ) External indexers (add later) │ - ├─────────────────────────────────────────────┤ - │ Step 4: Library Folders │ - │ TV root: [/media/tv ] [Browse] │ - │ Movie root: [/media/movies] [Browse] │ - ├─────────────────────────────────────────────┤ - │ Step 5: Quality Profile │ - │ [Create default profiles] │ - │ Or: [Import from existing Sonarr/Radarr] │ - ├─────────────────────────────────────────────┤ - │ Step 6: Authentication │ - │ Username: [________] │ - │ Password: [________] │ - └─────────────────────────────────────────────┘ - -3. POST /api/v1/setup/init with all selections -4. App initializes enabled modules, starts background services -5. Redirect to dashboard -``` - ---- - -## 11. Implementation Phases - -### Phase 1: Foundation (MVP — media library + manual search) -- [ ] Cargo workspace scaffold with all crates (empty) -- [ ] PostgreSQL schema + sqlx migrations -- [ ] `arz-core`: config loading, DB pool, error types, enabled modules -- [ ] `arz-metadata`: TMDB client (movies + TV) -- [ ] `arz-media`: Series/Episode/Movie CRUD, root folders -- [ ] `arz-parser`: Release name parser (quality, episodes, title extraction) -- [ ] `arz-web`: Axum server, auth, REST API for media CRUD -- [ ] First-boot API endpoint -- [ ] React UI: first-boot wizard, series/movie list, add/search media -- [ ] Docker: single container with Postgres - -### Phase 2: Search & Grab -- [ ] `arz-quality`: Quality profiles, custom formats, decision engine -- [ ] `arz-indexer`: Newznab/Torznab client, Indexarr client -- [ ] `arz-download`: Download client trait + external client implementations (qBit, SABnzbd) -- [ ] Search flow: manual search → decision engine → grab -- [ ] Queue tracking (poll download clients) -- [ ] History + blocklist -- [ ] UI: interactive search, queue view, history - -### Phase 3: Automated Workflows -- [ ] `arz-scheduler`: RSS sync, completed download polling -- [ ] `arz-import`: Completed download → scan → match → rename → move -- [ ] Naming config with token system -- [ ] Auto-search for missing/cutoff -- [ ] Calendar view -- [ ] Wanted views (missing + cutoff unmet) - -### Phase 4: Embedded Download Clients -- [ ] `arz-download`: EmbeddedTorrentClient wrapping `librtbit` -- [ ] `arz-download`: EmbeddedUsenetClient wrapping `nzb-*` crates -- [ ] First-boot options for embedded vs external -- [ ] UI: embedded client status panels -- [ ] Usenet server configuration in UI - -### Phase 5: Indexarr Integration -- [ ] Indexarr sidecar docker-compose -- [ ] `arz-indexer`: IndexarrClient (Torznab + REST) -- [ ] Auto-detect sidecar on startup -- [ ] Indexarr defaults to peer-only mode (sync, no crawling) -- [ ] UI: Indexarr status panel - -### Phase 6: Polish & Parity -- [ ] `arz-notify`: Notification providers (webhook, Discord, Telegram) -- [ ] Import lists (TMDB popular, Trakt watchlist, IMDB) -- [ ] Manual import (scan folder, match, import) -- [ ] Disk scan (detect files added outside the app) -- [ ] Custom format specifications (full regex engine) -- [ ] Scene name mapping / XEM integration -- [ ] Backup/restore -- [ ] API key auth for external tool integration -- [ ] Health check system - -### Phase 7: Migration Tools -- [ ] Import from Sonarr (SQLite → PostgreSQL migration) -- [ ] Import from Radarr (SQLite → PostgreSQL migration) -- [ ] Import from Prowlarr (indexer definitions) - ---- - -## 12. Key Dependencies - -```toml -[workspace.dependencies] -# Async -tokio = { version = "1", features = ["full"] } -# Web -axum = "0.8" -tower = "0.5" -tower-http = { version = "0.6", features = ["cors", "trace", "fs"] } -# Database -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "json", "uuid"] } -# HTTP client -reqwest = { version = "0.12", features = ["json", "rustls-tls"] } -# Serialization -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "0.8" -quick-xml = "0.37" # Newznab/Torznab XML parsing -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -# Error handling -thiserror = "2" -anyhow = "1" -# Util -chrono = { version = "0.4", features = ["serde"] } -uuid = { version = "1", features = ["v4", "serde"] } -regex = "1" -async-trait = "0.1" -# Embedded clients (optional) -librtbit = { path = "../rustTorrent/crates/librtbit", optional = true } -nzb-core = { path = "../rustnzbd/crates/nzb-core", optional = true } -nzb-web = { path = "../rustnzbd/crates/nzb-web", optional = true } -nzb-nntp = { path = "../rustnzbd/crates/nzb-nntp", optional = true } -nzb-decode = { path = "../rustnzbd/crates/nzb-decode", optional = true } -nzb-postproc = { path = "../rustnzbd/crates/nzb-postproc", optional = true } -``` - ---- - -## 13. Docker Deployment - -```yaml -# docker-compose.yml -services: - arz: - build: . - ports: - - "8989:8989" - volumes: - - ./config:/config - - /media/tv:/media/tv - - /media/movies:/media/movies - - /downloads:/downloads - environment: - - ARZ_DATABASE_URL=postgresql://arz:password@postgres:5432/arz - - ARZ_TORRENT_ENABLED=true - - ARZ_USENET_ENABLED=true - - ARZ_INDEXARR_ENABLED=true - - ARZ_INDEXARR_URL=http://indexarr:8080 - depends_on: - - postgres - - postgres: - image: postgres:17-alpine - volumes: - - pgdata:/var/lib/postgresql/data - environment: - - POSTGRES_USER=arz - - POSTGRES_PASSWORD=password - - POSTGRES_DB=arz - - # Optional sidecar - indexarr: - image: indexarr:latest - environment: - - INDEXARR_WORKERS=http_server,sync # peer-only mode - - INDEXARR_DB_BACKEND=sqlite - ports: - - "8080:8080" - profiles: - - indexarr - -volumes: - pgdata: -``` - ---- - -## Verification Plan - -### Phase 1 verification: -```bash -# Run migrations -sqlx database create && sqlx migrate run - -# Start app -cargo run -- --config config.toml - -# Test first-boot -curl http://localhost:8989/api/v1/system/status -# → should return { "firstBoot": true } - -# Complete setup -curl -X POST http://localhost:8989/api/v1/setup/init -d '...' - -# Add a series -curl -X POST http://localhost:8989/api/v1/series -d '{"tvdbId": 121361}' - -# Add a movie -curl -X POST http://localhost:8989/api/v1/movie -d '{"tmdbId": 550}' -``` - -### Integration test: -```bash -cargo test --workspace -# + docker compose test environment with Postgres -``` diff --git a/.claude/worktrees/agent-aa2f0435/client/.gitignore b/.claude/worktrees/agent-aa2f0435/client/.gitignore deleted file mode 100644 index a547bf36..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/.claude/worktrees/agent-aa2f0435/client/eslint.config.js b/.claude/worktrees/agent-aa2f0435/client/eslint.config.js deleted file mode 100644 index 5e6b472f..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/eslint.config.js +++ /dev/null @@ -1,23 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - }, -]) diff --git a/.claude/worktrees/agent-aa2f0435/client/index.html b/.claude/worktrees/agent-aa2f0435/client/index.html deleted file mode 100644 index b9fcde6a..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - StackArr Player - - - -
- - - diff --git a/.claude/worktrees/agent-aa2f0435/client/package-lock.json b/.claude/worktrees/agent-aa2f0435/client/package-lock.json deleted file mode 100644 index b4300b49..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/package-lock.json +++ /dev/null @@ -1,3285 +0,0 @@ -{ - "name": "client", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "client", - "version": "0.0.0", - "dependencies": { - "@tanstack/react-query": "^5.95.2", - "hls.js": "^1.6.15", - "lucide-react": "^1.7.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-router-dom": "^7.13.2" - }, - "devDependencies": { - "@eslint/js": "^9.39.4", - "@tauri-apps/cli": "^2", - "@types/node": "^24.12.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tanstack/query-core": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz", - "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.95.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz", - "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.95.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", - "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", - "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", - "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", - "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", - "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", - "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", - "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", - "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", - "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", - "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", - "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", - "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", - "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", - "dev": true, - "license": "ISC" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": "^9 || ^10" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/hls.js": { - "version": "1.6.15", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", - "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", - "license": "Apache-2.0" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", - "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", - "license": "MIT", - "dependencies": { - "react-router": "7.13.2" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", - "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.2", - "@typescript-eslint/parser": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/client/package.json b/.claude/worktrees/agent-aa2f0435/client/package.json deleted file mode 100644 index 50e1af0c..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "client", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview", - "tauri": "tauri", - "tauri:dev": "tauri dev", - "tauri:build": "tauri build" - }, - "dependencies": { - "@tanstack/react-query": "^5.95.2", - "hls.js": "^1.6.15", - "lucide-react": "^1.7.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-router-dom": "^7.13.2" - }, - "devDependencies": { - "@eslint/js": "^9.39.4", - "@tauri-apps/cli": "^2", - "@types/node": "^24.12.0", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.57.0", - "vite": "^8.0.1" - } -} diff --git a/.claude/worktrees/agent-aa2f0435/client/public/favicon.svg b/.claude/worktrees/agent-aa2f0435/client/public/favicon.svg deleted file mode 100644 index 6893eb13..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/.claude/worktrees/agent-aa2f0435/client/public/icons.svg b/.claude/worktrees/agent-aa2f0435/client/public/icons.svg deleted file mode 100644 index e9522193..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-192.png b/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-192.png deleted file mode 100644 index ffcde225..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-192.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-512.png b/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-512.png deleted file mode 100644 index 4dce3ee2..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-512.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-96.png b/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-96.png deleted file mode 100644 index 85350c46..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/public/icons/icon-96.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/public/icons/maskable-512.png b/.claude/worktrees/agent-aa2f0435/client/public/icons/maskable-512.png deleted file mode 100644 index 4dce3ee2..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/public/icons/maskable-512.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/public/manifest.json b/.claude/worktrees/agent-aa2f0435/client/public/manifest.json deleted file mode 100644 index b39c4a80..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/public/manifest.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "StackArr Player", - "short_name": "StackArr", - "description": "Stream your media library anywhere", - "start_url": "/app/", - "scope": "/app/", - "display": "standalone", - "background_color": "#0f172a", - "theme_color": "#1e40af", - "icons": [ - { - "src": "/app/icons/icon-96.png", - "sizes": "96x96", - "type": "image/png" - }, - { - "src": "/app/icons/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/app/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "/app/icons/maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/.claude/worktrees/agent-aa2f0435/client/public/sw.js b/.claude/worktrees/agent-aa2f0435/client/public/sw.js deleted file mode 100644 index 2ca9102e..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/public/sw.js +++ /dev/null @@ -1,93 +0,0 @@ -const CACHE_NAME = 'stackarr-shell-v1'; -const SHELL_URLS = [ - '/app/', - '/app/index.html', -]; - -// Install: cache app shell -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS)) - ); - self.skipWaiting(); -}); - -// Activate: clean old caches -self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) - ) - ); - self.clients.claim(); -}); - -// Fetch: network-first for API, cache-first for shell assets -self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - - // API requests: network-first - if (url.pathname.startsWith('/api/')) { - event.respondWith( - fetch(event.request).catch(() => caches.match(event.request)) - ); - return; - } - - // App shell: cache-first, fallback to network - event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) return cached; - return fetch(event.request).then((response) => { - // Cache JS/CSS assets on the fly - if (response.ok && (url.pathname.endsWith('.js') || url.pathname.endsWith('.css'))) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); - } - return response; - }); - }) - ); -}); - -// Push notification handler -self.addEventListener('push', (event) => { - let data = { title: 'StackArr', body: 'New notification' }; - try { - if (event.data) { - data = event.data.json(); - } - } catch { - if (event.data) { - data = { title: 'StackArr', body: event.data.text() }; - } - } - - event.waitUntil( - self.registration.showNotification(data.title || 'StackArr', { - body: data.body || '', - icon: '/app/icons/icon-192.png', - badge: '/app/icons/icon-96.png', - data: data.data || {}, - tag: data.tag || 'stackarr-notification', - }) - ); -}); - -// Notification click: open or focus the app -self.addEventListener('notificationclick', (event) => { - event.notification.close(); - - const urlToOpen = '/app/'; - - event.waitUntil( - self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { - for (const client of clients) { - if (client.url.includes('/app') && 'focus' in client) { - return client.focus(); - } - } - return self.clients.openWindow(urlToOpen); - }) - ); -}); diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/.gitignore b/.claude/worktrees/agent-aa2f0435/client/src-tauri/.gitignore deleted file mode 100644 index 4cacddf4..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -/gen diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/Cargo.toml b/.claude/worktrees/agent-aa2f0435/client/src-tauri/Cargo.toml deleted file mode 100644 index a09bd0d4..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "stackarr-player" -version = "0.1.0" -edition = "2024" -license = "MIT" -description = "StackArr streaming client" - -[lib] -name = "stackarr_player" -crate-type = ["staticlib", "cdylib", "rlib"] - -[build-dependencies] -tauri-build = { version = "2", features = [] } - -[dependencies] -tauri = { version = "2", features = [] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/build.rs b/.claude/worktrees/agent-aa2f0435/client/src-tauri/build.rs deleted file mode 100644 index 261851f6..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build(); -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/capabilities/default.json b/.claude/worktrees/agent-aa2f0435/client/src-tauri/capabilities/default.json deleted file mode 100644 index 177e9e81..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/capabilities/default.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json", - "identifier": "default", - "description": "Default capability for the StackArr Player", - "windows": ["main"], - "permissions": [ - "core:default" - ] -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/128x128.png b/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/128x128.png deleted file mode 100644 index a33d3b8c..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/128x128.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/128x128@2x.png b/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/128x128@2x.png deleted file mode 100644 index 88d049ac..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/32x32.png b/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/32x32.png deleted file mode 100644 index 74bab88a..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/32x32.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.icns b/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.icns deleted file mode 100644 index f5ebbe1b..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.icns and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.ico b/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.ico deleted file mode 100644 index 8c87763c..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.ico and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.png b/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.png deleted file mode 100644 index 88d049ac..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/client/src-tauri/icons/icon.png and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/src/lib.rs b/.claude/worktrees/agent-aa2f0435/client/src-tauri/src/lib.rs deleted file mode 100644 index 3b48e91a..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/src/main.rs b/.claude/worktrees/agent-aa2f0435/client/src-tauri/src/main.rs deleted file mode 100644 index b5e3ed9d..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Prevents additional console window on Windows in release -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - stackarr_player::run(); -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src-tauri/tauri.conf.json b/.claude/worktrees/agent-aa2f0435/client/src-tauri/tauri.conf.json deleted file mode 100644 index 4e8cfecf..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src-tauri/tauri.conf.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", - "productName": "StackArr Player", - "version": "0.1.0", - "identifier": "com.stackarr.player", - "build": { - "frontendDist": "../dist", - "devUrl": "http://localhost:3001", - "beforeDevCommand": "npm run dev", - "beforeBuildCommand": "npm run build" - }, - "app": { - "windows": [ - { - "title": "StackArr Player", - "width": 1280, - "height": 800, - "minWidth": 800, - "minHeight": 600, - "resizable": true, - "fullscreen": false - } - ], - "security": { - "csp": "default-src 'self'; connect-src 'self' http: https: ws: wss:; img-src 'self' http: https: data:; media-src 'self' http: https: blob:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" - } - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ] - } -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/App.tsx b/.claude/worktrees/agent-aa2f0435/client/src/App.tsx deleted file mode 100644 index 559c3ff3..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/App.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useState } from 'react' -import { Routes, Route, NavLink } from 'react-router-dom' -import { Tv, Film, Home, LogOut, User, Search, ListChecks, Bookmark } from 'lucide-react' -import Browse from './pages/Browse' -import HomePage from './pages/HomePage' -import SeriesView from './pages/SeriesView' -import MovieView from './pages/MovieView' -import Player from './pages/Player' -import DiscoverPage from './pages/DiscoverPage' -import RequestsPage from './pages/RequestsPage' -import WatchlistPage from './pages/WatchlistPage' -import ServerConnect from './pages/ServerConnect' -import LoginPage from './pages/LoginPage' -import RegisterPage from './pages/RegisterPage' -import { useAuth } from './context/AuthContext' -import { getConnection, clearConnection } from './api' -import NotificationBell from './components/NotificationBell' - -const navStyle = (active: boolean) => ({ - display: 'flex', - alignItems: 'center', - gap: 8, - padding: '8px 16px', - borderRadius: 8, - fontSize: 14, - fontWeight: 500, - textDecoration: 'none', - color: active ? '#fff' : '#94a3b8', - background: active ? '#1e40af' : 'transparent', - transition: 'all 0.15s', -}) - -type ConnState = 'checking' | 'connected' | 'needs_setup' - -export default function App() { - const [connState, setConnState] = useState('checking') - const [authPage, setAuthPage] = useState<'login' | 'register'>('login') - const [pendingInviteCode, setPendingInviteCode] = useState(null) - const { user, loading: authLoading, logout } = useAuth() - - useState(() => { - checkConnection() - }) - - async function checkConnection() { - const conn = getConnection() - if (conn) { - try { - const res = await fetch(`${conn.serverUrl}/api/v1/system/status`, { - headers: { Authorization: `Bearer ${conn.clientToken}` }, - signal: AbortSignal.timeout(5000), - }) - if (res.ok) { - setConnState('connected') - return - } - } catch { - // Fall through - } - clearConnection() - setConnState('needs_setup') - return - } - - // Try same-origin - try { - const res = await fetch('/api/v1/system/status', { - credentials: 'include', - signal: AbortSignal.timeout(3000), - }) - if (res.ok) { - const ct = res.headers.get('content-type') || '' - if (ct.includes('application/json')) { - const data = await res.json() - if (data && typeof data.version === 'string') { - setConnState('connected') - return - } - } - } - } catch { - // Not available - } - - setConnState('needs_setup') - } - - if (connState === 'checking' || authLoading) { - return ( -
- Connecting... -
- ) - } - - if (connState === 'needs_setup') { - return { - setConnState('connected') - if (opts?.claimType === 'invite' && opts?.inviteCode) { - setPendingInviteCode(opts.inviteCode) - } - }} /> - } - - // If connected but no user session, show login/register - if (!user) { - if (pendingInviteCode || authPage === 'register') { - return ( - { setPendingInviteCode(null); setAuthPage('login') }} - inviteCode={pendingInviteCode} - /> - ) - } - return setAuthPage('register')} /> - } - - const conn = getConnection() - - return ( -
- {/* Header */} -
- StackArr Player - -
- - - - {user.displayName} - - {conn && ( - {conn.serverName} - )} - -
-
- - {/* Content */} -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/api.ts b/.claude/worktrees/agent-aa2f0435/client/src/api.ts deleted file mode 100644 index 0d468a25..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/api.ts +++ /dev/null @@ -1,413 +0,0 @@ -// ── Connection management ─────────────────────────────────────────────────── - -export interface ServerConnection { - serverUrl: string - serverName: string - serverId: string - clientToken?: string -} - -const STORAGE_KEY = 'stackarr_server' - -export function getConnection(): ServerConnection | null { - try { - const stored = localStorage.getItem(STORAGE_KEY) - return stored ? JSON.parse(stored) : null - } catch { - return null - } -} - -export function saveConnection(conn: ServerConnection) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(conn)) -} - -export function clearConnection() { - localStorage.removeItem(STORAGE_KEY) -} - -function getApiBase(): string { - const conn = getConnection() - return conn ? `${conn.serverUrl}/api/v1` : '/api/v1' -} - -function authHeaders(): Record { - const conn = getConnection() - if (conn?.clientToken) { - return { Authorization: `Bearer ${conn.clientToken}` } - } - return {} -} - -// ── HTTP helpers ──────────────────────────────────────────────────────────── - -async function get(path: string): Promise { - const res = await fetch(`${getApiBase()}${path}`, { - headers: authHeaders(), - credentials: 'include', - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return res.json() -} - -async function post(path: string, body?: unknown): Promise { - const res = await fetch(`${getApiBase()}${path}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - credentials: 'include', - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return res.json() -} - -async function put(path: string, body?: unknown): Promise { - const res = await fetch(`${getApiBase()}${path}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - credentials: 'include', - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) - return res.json() -} - -async function del(path: string, body?: unknown): Promise { - const res = await fetch(`${getApiBase()}${path}`, { - method: 'DELETE', - headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(), - credentials: 'include', - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) -} - -// ── Bootstrap discovery ───────────────────────────────────────────────────── - -interface ClaimRedeemResponse { - serverId: string - serverName: string - publicIp: string - localIps: string[] - port: number - version: string - claimType: string // "invite" or "device" - inviteCode?: string // present if claimType == "invite" - clientToken?: string // legacy, may not be present -} - -export interface ClaimResult extends ServerConnection { - claimType: string - inviteCode?: string -} - -export async function redeemClaimCode( - code: string, - bootstrapUrl: string, -): Promise { - const res = await fetch(`${bootstrapUrl}/api/v1/claims/${code.toUpperCase()}/redeem`, { - method: 'POST', - }) - if (!res.ok) { - if (res.status === 404) throw new Error('Invalid or expired invite code') - throw new Error(`Bootstrap error: ${res.status}`) - } - const data: ClaimRedeemResponse = await res.json() - - // Try local IPs first, then public IP - const urls = [ - ...data.localIps.map((ip: string) => `http://${ip}:${data.port}`), - `http://${data.publicIp}:${data.port}`, - ] - - for (const url of urls) { - try { - const probe = await fetch(`${url}/api/v1/system/status`, { - signal: AbortSignal.timeout(3000), - }) - if (probe.ok) { - const conn: ServerConnection = { - serverUrl: url, - serverName: data.serverName, - serverId: data.serverId, - clientToken: data.clientToken ?? '', - } - saveConnection(conn) - - return { - ...conn, - claimType: data.claimType, - inviteCode: data.inviteCode, - } - } - } catch { - // Try next URL - } - } - - throw new Error('Server found but unreachable. Check your network/firewall.') -} - -// ── Types ─────────────────────────────────────────────────────────────────── - -interface Image { - coverType: string - remoteUrl: string -} - -export interface Series { - id: number - title: string - sortTitle: string - overview: string | null - status: string - network: string | null - year: number | null - images: Image[] | null -} - -export interface Episode { - id: number - seriesId: number - seasonNumber: number - episodeNumber: number - title: string | null - overview: string | null - monitored: boolean - episodeFileId: number | null -} - -export interface Movie { - id: number - title: string - sortTitle: string - overview: string | null - year: number | null - studio: string | null - movieFileId: number | null - images: Image[] | null -} - -export interface StreamInfo { - container: string - durationSecs: number - bitrate: number - videoStreams: { - index: number; codec: string; width: number; height: number - bitrate: number; profile: string; isHdr: boolean; frameRate: number - }[] - audioStreams: { - index: number; codec: string; channels: number; language: string - title: string; bitrate: number; isDefault: boolean - }[] - subtitleStreams: { - index: number; codec: string; language: string; title: string - forced: boolean; isDefault: boolean - }[] -} - -export interface TranscodeResponse { - sessionId: string - playlistUrl: string -} - -export interface WatchProgress { - id: number - userId: number - mediaFileId: number - mediaType: string - mediaId: number - episodeId: number | null - positionSecs: number - durationSecs: number - completed: boolean - updatedAt: string -} - -export interface ContinueWatchingItem extends WatchProgress { - title: string | null - posterUrl: string | null - backdropUrl: string | null - episodeTitle: string | null - seasonNumber: number | null - episodeNumber: number | null - year: number | null -} - -// ── Media Requests ────────────────────────────────────────────────────────── - -export interface MediaRequest { - id: number - userId: number - mediaType: string - tmdbId: number - title: string - year: number | null - posterUrl: string | null - overview: string | null - status: string - adminNote: string | null - approvedBy: number | null - createdAt: string - updatedAt: string -} - -export interface DiscoverResult { - id: number - title?: string - name?: string - overview?: string - releaseDate?: string - firstAirDate?: string - posterPath?: string | null - backdropPath?: string | null - voteAverage: number - mediaType: string - inLibrary: boolean - requestStatus: string | null -} - -export interface DiscoverSearchResults { - page: number - totalPages: number - totalResults: number - results: DiscoverResult[] -} - -// ── Watchlist ─────────────────────────────────────────────────────────────── - -export interface WatchlistItem { - id: number - userId: number - mediaType: string - mediaId: number - tmdbId: number - addedAt: string - title: string | null - posterUrl: string | null - year: number | null -} - -// ── Ratings ───────────────────────────────────────────────────────────────── - -export interface UserRating { - id: number - userId: number - mediaType: string - mediaId: number - rating: number - createdAt: string - updatedAt: string -} - -export interface RatingInfo { - userRating: number | null - averageRating: number - ratingCount: number -} - -// ── Notifications ──────────────────────────────────────────────────────────── - -export interface UserNotification { - id: number - userId: number - notificationType: string - title: string - body: string | null - data: Record | null - read: boolean - createdAt: string -} - -export interface UnreadCount { - count: number -} - -// Helpers - -export function imageUrl(images: Image[] | null, type: 'poster' | 'fanart' | 'banner'): string | null { - if (!images) return null - const img = images.find((i) => i.coverType === type) - return img?.remoteUrl ?? null -} - -export const api = { - listSeries: () => get('/series'), - getSeries: (id: number) => get(`/series/${id}`), - getEpisodes: (seriesId: number) => get(`/series/${seriesId}/episodes`), - listMovies: () => get('/movies'), - getMovie: (id: number) => get(`/movies/${id}`), - streamInfo: (fileId: number) => get(`/stream/${fileId}/info`), - startTranscode: (fileId: number, opts?: Record) => - post(`/stream/${fileId}/transcode`, opts ?? {}), - directPlayUrl: (fileId: number) => `${getApiBase()}/stream/${fileId}/direct`, - hlsUrl: (fileId: number, sessionId: string) => - `${getApiBase()}/stream/${fileId}/hls/${sessionId}/master.m3u8`, - subtitleUrl: (fileId: number, trackIndex: number) => - `${getApiBase()}/stream/${fileId}/subtitles/${trackIndex}`, - - // Progress - getContinueWatching: (limit = 20) => - get(`/user/progress/continue?limit=${limit}`), - getProgress: (mediaFileId: number) => - get(`/user/progress/${mediaFileId}`), - getProgressSafe: async (mediaFileId: number): Promise => { - try { - return await get(`/user/progress/${mediaFileId}`) - } catch { - return null - } - }, - updateProgress: (mediaFileId: number, positionSecs: number, durationSecs: number) => - put(`/user/progress/${mediaFileId}`, { positionSecs, durationSecs }), - deleteProgress: (mediaFileId: number) => - del(`/user/progress/${mediaFileId}`), - getSeriesProgress: (seriesId: number) => - get(`/user/progress/series/${seriesId}`), - getMovieProgress: (movieId: number) => - get(`/user/progress/movie/${movieId}`), - - // Discover (enriched search) - discoverSearch: (q: string, type: 'movie' | 'series' = 'movie') => - get(`/discover/search?q=${encodeURIComponent(q)}&type=${type}`), - - // Requests - listMyRequests: () => get('/requests?mine=true'), - createRequest: (body: { - mediaType: string; tmdbId: number; title: string; - year?: number; posterUrl?: string; overview?: string; - }) => post('/requests', body), - - // Watchlist - getWatchlist: (mediaType?: string) => - get(`/user/watchlist${mediaType ? `?mediaType=${mediaType}` : ''}`), - addToWatchlist: (mediaType: string, mediaId: number) => - put(`/user/watchlist/${mediaType}/${mediaId}`), - removeFromWatchlist: (mediaType: string, mediaId: number) => - del(`/user/watchlist/${mediaType}/${mediaId}`), - - // Ratings - getUserRatings: (mediaType?: string) => - get(`/user/ratings${mediaType ? `?mediaType=${mediaType}` : ''}`), - setRating: (mediaType: string, mediaId: number, rating: number) => - put(`/user/ratings/${mediaType}/${mediaId}`, { rating }), - getRating: (mediaType: string, mediaId: number) => - get(`/user/ratings/${mediaType}/${mediaId}`), - deleteRating: (mediaType: string, mediaId: number) => - del(`/user/ratings/${mediaType}/${mediaId}`), - - // Notifications - getNotifications: (unread = false, limit = 50, offset = 0) => - get( - `/user/notifications?unread=${unread}&limit=${limit}&offset=${offset}`, - ), - getUnreadCount: () => get('/user/notifications/unread-count'), - markNotificationRead: (id: number) => - put<{ ok: boolean }>(`/user/notifications/${id}/read`), - markAllNotificationsRead: () => - put<{ marked: number }>('/user/notifications/read-all'), - savePushSubscription: (endpoint: string, p256dh: string, auth: string) => - post('/user/push-subscription', { endpoint, p256dh, auth }), - removePushSubscription: (endpoint: string) => - del('/user/push-subscription', { endpoint }), -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/DiscoverGrid.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/DiscoverGrid.tsx deleted file mode 100644 index 950f77f7..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/DiscoverGrid.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { Check, Clock, Library, Plus, X } from 'lucide-react' -import type { DiscoverResult } from '../api' - -const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p' - -function getTitle(item: DiscoverResult): string { - return item.title || item.name || 'Unknown' -} - -function getYear(item: DiscoverResult): string { - const date = item.releaseDate || item.firstAirDate - return date?.substring(0, 4) || '' -} - -function statusButton(item: DiscoverResult, onRequest: (item: DiscoverResult) => void) { - if (item.inLibrary) { - return ( - - In Library - - ) - } - if (item.requestStatus === 'pending') { - return ( - - Requested - - ) - } - if (item.requestStatus === 'approved') { - return ( - - Approved - - ) - } - if (item.requestStatus === 'declined') { - return ( - - Declined - - ) - } - return ( - - ) -} - -export default function DiscoverGrid({ - results, - onRequest, -}: { - results: DiscoverResult[] - onRequest: (item: DiscoverResult) => void -}) { - return ( -
- {results.map((item) => ( -
{ - e.currentTarget.style.transform = 'scale(1.03)' - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'scale(1)' - }} - > -
- {item.posterPath ? ( - {getTitle(item)} - ) : ( -
- No poster -
- )} - {item.voteAverage > 0 && ( - - {item.voteAverage.toFixed(1)} - - )} -
-
-
- {getTitle(item)} -
-
- {getYear(item)} · {item.mediaType === 'series' ? 'TV' : 'Movie'} -
- {statusButton(item, onRequest)} -
-
- ))} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/MediaRow.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/MediaRow.tsx deleted file mode 100644 index 13eae833..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/MediaRow.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useNavigate } from 'react-router-dom' -import type { ContinueWatchingItem } from '../api' - -interface MediaRowProps { - title: string - items: ContinueWatchingItem[] -} - -function formatProgress(item: ContinueWatchingItem): string { - const pct = item.durationSecs > 0 - ? Math.round((item.positionSecs / item.durationSecs) * 100) - : 0 - return `${pct}%` -} - -function formatEpisode(item: ContinueWatchingItem): string { - if (item.mediaType === 'series' && item.seasonNumber != null && item.episodeNumber != null) { - const ep = `S${String(item.seasonNumber).padStart(2, '0')}E${String(item.episodeNumber).padStart(2, '0')}` - return item.episodeTitle ? `${ep} - ${item.episodeTitle}` : ep - } - return '' -} - -function posterSrc(item: ContinueWatchingItem): string | undefined { - if (item.posterUrl) return item.posterUrl - return undefined -} - -export default function MediaRow({ title, items }: MediaRowProps) { - const navigate = useNavigate() - - if (items.length === 0) return null - - return ( -
-

- {title} -

-
- {items.map((item) => { - const progressPct = item.durationSecs > 0 - ? (item.positionSecs / item.durationSecs) * 100 - : 0 - - return ( -
navigate(`/play/${item.mediaFileId}`)} - style={{ - flex: '0 0 160px', - cursor: 'pointer', - borderRadius: 10, - overflow: 'hidden', - background: '#1e293b', - transition: 'transform 0.15s, box-shadow 0.15s', - }} - onMouseEnter={(e) => { - e.currentTarget.style.transform = 'scale(1.04)' - e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.4)' - }} - onMouseLeave={(e) => { - e.currentTarget.style.transform = 'scale(1)' - e.currentTarget.style.boxShadow = 'none' - }} - > - {/* Poster */} -
- {posterSrc(item) ? ( - {item.title - ) : ( -
- {item.title || 'Unknown'} -
- )} - {/* Progress bar overlay */} -
-
-
-
- - {/* Info */} -
-
- {item.title || 'Unknown'} -
- {item.mediaType === 'series' && ( -
- {formatEpisode(item)} -
- )} -
- {formatProgress(item)} watched -
-
-
- ) - })} -
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationBell.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationBell.tsx deleted file mode 100644 index 5481b234..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationBell.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { Bell } from 'lucide-react' -import { api } from '../api' -import type { UserNotification } from '../api' -import NotificationDropdown from './NotificationDropdown' - -export default function NotificationBell() { - const [open, setOpen] = useState(false) - const [unread, setUnread] = useState(0) - const [notifications, setNotifications] = useState([]) - const ref = useRef(null) - - const fetchUnread = useCallback(async () => { - try { - const data = await api.getUnreadCount() - setUnread(data.count) - } catch { - // Silently ignore polling errors - } - }, []) - - const fetchNotifications = useCallback(async () => { - try { - const data = await api.getNotifications(false, 50, 0) - setNotifications(data) - } catch { - // Silently ignore - } - }, []) - - // Poll unread count every 30s - useEffect(() => { - fetchUnread() - const interval = setInterval(fetchUnread, 30_000) - return () => clearInterval(interval) - }, [fetchUnread]) - - // When dropdown opens, fetch full list - useEffect(() => { - if (open) { - fetchNotifications() - } - }, [open, fetchNotifications]) - - // Close on click outside - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false) - } - } - if (open) { - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - } - }, [open]) - - const handleMarkRead = async (id: number) => { - try { - await api.markNotificationRead(id) - setNotifications(prev => - prev.map(n => (n.id === id ? { ...n, read: true } : n)), - ) - setUnread(prev => Math.max(0, prev - 1)) - } catch { - // ignore - } - } - - const handleMarkAllRead = async () => { - try { - await api.markAllNotificationsRead() - setNotifications(prev => prev.map(n => ({ ...n, read: true }))) - setUnread(0) - } catch { - // ignore - } - } - - return ( -
- - {open && ( - - )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationDropdown.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationDropdown.tsx deleted file mode 100644 index ac8a29c0..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationDropdown.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { UserNotification } from '../api' -import NotificationItem from './NotificationItem' - -interface Props { - notifications: UserNotification[] - onMarkRead: (id: number) => void - onMarkAllRead: () => void - onNavigate?: (path: string) => void -} - -export default function NotificationDropdown({ - notifications, - onMarkRead, - onMarkAllRead, - onNavigate, -}: Props) { - return ( -
- {/* Header */} -
- - Notifications - - {notifications.some(n => !n.read) && ( - - )} -
- - {/* List */} -
- {notifications.length === 0 ? ( -
- No notifications -
- ) : ( - notifications.map(n => ( - - )) - )} -
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationItem.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationItem.tsx deleted file mode 100644 index 98896487..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/NotificationItem.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { UserNotification } from '../api' - -function relativeTime(dateStr: string): string { - const now = Date.now() - const then = new Date(dateStr).getTime() - const diffSec = Math.floor((now - then) / 1000) - - if (diffSec < 60) return 'just now' - if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago` - if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago` - if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d ago` - return new Date(dateStr).toLocaleDateString() -} - -interface Props { - notification: UserNotification - onMarkRead: (id: number) => void - onNavigate?: (path: string) => void -} - -export default function NotificationItem({ notification, onMarkRead, onNavigate }: Props) { - const handleClick = () => { - if (!notification.read) { - onMarkRead(notification.id) - } - // Deep link based on notification data - if (notification.data && onNavigate) { - const d = notification.data - if (d.type === 'new_episode' || d.type === 'request_update') { - // Could navigate to series/movie detail in the future - } - } - } - - return ( -
- {/* Unread dot */} - {!notification.read && ( -
- )} -
-
- {notification.title} -
- {notification.body && ( -
- {notification.body} -
- )} -
- {relativeTime(notification.createdAt)} -
-
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/ProgressReporter.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/ProgressReporter.tsx deleted file mode 100644 index 39628540..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/ProgressReporter.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react' -import { api } from '../api' - -interface ProgressReporterProps { - videoRef: React.RefObject - mediaFileId: number - intervalMs?: number -} - -/** - * Reports video playback position to the server every `intervalMs` (default 10s), - * and on pause/ended events. - */ -export default function ProgressReporter({ - videoRef, - mediaFileId, - intervalMs = 10_000, -}: ProgressReporterProps) { - const lastReported = useRef(0) - - const report = useCallback(() => { - const video = videoRef.current - if (!video || video.readyState < 1) return - const pos = video.currentTime - const dur = video.duration - if (!dur || isNaN(dur) || dur <= 0) return - // Avoid spamming identical reports - if (Math.abs(pos - lastReported.current) < 1) return - lastReported.current = pos - api.updateProgress(mediaFileId, pos, dur).catch(() => {}) - }, [videoRef, mediaFileId]) - - // Periodic reporting - useEffect(() => { - const timer = setInterval(report, intervalMs) - return () => clearInterval(timer) - }, [report, intervalMs]) - - // Report on pause and ended - useEffect(() => { - const video = videoRef.current - if (!video) return - - const handler = () => report() - video.addEventListener('pause', handler) - video.addEventListener('ended', handler) - return () => { - video.removeEventListener('pause', handler) - video.removeEventListener('ended', handler) - } - }, [videoRef, report]) - - // Report on unmount - useEffect(() => { - return () => { report() } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return null -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/RatingStars.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/RatingStars.tsx deleted file mode 100644 index 23bfa497..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/RatingStars.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useState, useEffect } from 'react' -import { Star } from 'lucide-react' -import { api } from '../api' - -interface Props { - mediaType: 'series' | 'movie' - mediaId: number -} - -export default function RatingStars({ mediaType, mediaId }: Props) { - const [userRating, setUserRating] = useState(null) - const [averageRating, setAverageRating] = useState(0) - const [ratingCount, setRatingCount] = useState(0) - const [hoverStar, setHoverStar] = useState(0) - const [loading, setLoading] = useState(true) - - useEffect(() => { - api - .getRating(mediaType, mediaId) - .then((info) => { - setUserRating(info.userRating) - setAverageRating(info.averageRating) - setRatingCount(info.ratingCount) - }) - .catch(() => {}) - .finally(() => setLoading(false)) - }, [mediaType, mediaId]) - - // Convert 1-10 scale to 1-5 stars for display - // Stars 1-5 map to ratings 2,4,6,8,10 - const starValue = userRating ? Math.round(userRating / 2) : 0 - const displayStar = hoverStar || starValue - - const handleClick = async (star: number) => { - const rating = star * 2 // Convert star (1-5) to rating (2-10) - try { - if (userRating === rating) { - // Click same rating to remove - await api.deleteRating(mediaType, mediaId) - setUserRating(null) - // Refresh average - const info = await api.getRating(mediaType, mediaId) - setAverageRating(info.averageRating) - setRatingCount(info.ratingCount) - } else { - const result = await api.setRating(mediaType, mediaId, rating) - setUserRating(result.rating) - // Refresh average - const info = await api.getRating(mediaType, mediaId) - setAverageRating(info.averageRating) - setRatingCount(info.ratingCount) - } - } catch (e) { - console.error('Rating failed:', e) - } - } - - if (loading) return null - - const avgStars = averageRating / 2 - - return ( -
-
setHoverStar(0)} - > - {[1, 2, 3, 4, 5].map((star) => ( - - ))} - {userRating != null && ( - - {userRating}/10 - - )} -
- {ratingCount > 0 && ( - - Avg: {avgStars.toFixed(1)}/5 ({ratingCount} {ratingCount === 1 ? 'rating' : 'ratings'}) - - )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/RequestCard.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/RequestCard.tsx deleted file mode 100644 index 5d459ce7..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/RequestCard.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Clock, Check, X, Library } from 'lucide-react' -import type { MediaRequest } from '../api' - -const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p' - -function statusBadge(status: string) { - const styles: Record = { - pending: { bg: '#854d0e', color: '#fbbf24', label: 'Pending' }, - approved: { bg: '#166534', color: '#4ade80', label: 'Approved' }, - declined: { bg: '#991b1b', color: '#f87171', label: 'Declined' }, - available: { bg: '#1e40af', color: '#60a5fa', label: 'Available' }, - } - const s = styles[status] || styles.pending - return ( - - {status === 'pending' && } - {status === 'approved' && } - {status === 'declined' && } - {status === 'available' && } - {s.label} - - ) -} - -export default function RequestCard({ request }: { request: MediaRequest }) { - const poster = request.posterUrl - ? `/api/v1/images/${TMDB_IMAGE_BASE}/w342${request.posterUrl}` - : null - - return ( -
-
- {poster ? ( - {request.title} - ) : ( -
- No poster -
- )} -
-
-
- - {request.title} - - {request.year && ( - ({request.year}) - )} -
-
- {statusBadge(request.status)} - - {request.mediaType === 'series' ? 'TV' : 'Movie'} - -
- {request.overview && ( -

- {request.overview} -

- )} - {request.adminNote && ( -

- Admin: {request.adminNote} -

- )} -
- Requested {new Date(request.createdAt).toLocaleDateString()} -
-
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/components/WatchlistButton.tsx b/.claude/worktrees/agent-aa2f0435/client/src/components/WatchlistButton.tsx deleted file mode 100644 index dc3df1e5..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/components/WatchlistButton.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useState, useEffect } from 'react' -import { Bookmark } from 'lucide-react' -import { api } from '../api' - -interface Props { - mediaType: 'series' | 'movie' - mediaId: number -} - -export default function WatchlistButton({ mediaType, mediaId }: Props) { - const [onWatchlist, setOnWatchlist] = useState(false) - const [loading, setLoading] = useState(true) - - useEffect(() => { - api - .getWatchlist(mediaType) - .then((items) => { - setOnWatchlist(items.some((i) => i.mediaId === mediaId)) - }) - .catch(() => {}) - .finally(() => setLoading(false)) - }, [mediaType, mediaId]) - - const toggle = async () => { - try { - if (onWatchlist) { - await api.removeFromWatchlist(mediaType, mediaId) - setOnWatchlist(false) - } else { - await api.addToWatchlist(mediaType, mediaId) - setOnWatchlist(true) - } - } catch (e) { - console.error('Watchlist toggle failed:', e) - } - } - - if (loading) return null - - return ( - - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/context/AuthContext.tsx b/.claude/worktrees/agent-aa2f0435/client/src/context/AuthContext.tsx deleted file mode 100644 index c23f94a5..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/context/AuthContext.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' - -export interface AuthUser { - id: number - username: string - displayName: string - role: string - avatarUrl: string | null - authMethod?: string -} - -interface AuthContextValue { - user: AuthUser | null - loading: boolean - login: (username: string, password: string) => Promise - register: (username: string, password: string, displayName: string, inviteCode: string) => Promise - logout: () => Promise -} - -const AuthContext = createContext(null) - -function getApiBase(): string { - const stored = localStorage.getItem('stackarr_server') - if (stored) { - try { - const conn = JSON.parse(stored) - return `${conn.serverUrl}/api/v1` - } catch { /* fall through */ } - } - return '/api/v1' -} - -export function AuthProvider({ children }: { children: ReactNode }) { - const [user, setUser] = useState(null) - const [loading, setLoading] = useState(true) - - const fetchMe = useCallback(async () => { - try { - const res = await fetch(`${getApiBase()}/auth/me`, { - credentials: 'include', - }) - if (res.ok) { - const data = await res.json() - setUser(data) - } else { - setUser(null) - } - } catch { - setUser(null) - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchMe() - }, [fetchMe]) - - const login = useCallback(async (username: string, password: string) => { - const res = await fetch(`${getApiBase()}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ username, password }), - }) - if (!res.ok) { - const body = await res.json().catch(() => ({ error: 'Login failed' })) - throw new Error(body.error || 'Login failed') - } - const data = await res.json() - setUser(data.user) - }, []) - - const register = useCallback(async ( - username: string, - password: string, - displayName: string, - inviteCode: string, - ) => { - const res = await fetch(`${getApiBase()}/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ username, password, displayName, inviteCode }), - }) - if (!res.ok) { - const body = await res.json().catch(() => ({ error: 'Registration failed' })) - throw new Error(body.error || 'Registration failed') - } - const data = await res.json() - setUser(data.user) - }, []) - - const logout = useCallback(async () => { - try { - await fetch(`${getApiBase()}/auth/logout`, { - method: 'POST', - credentials: 'include', - }) - } catch { - // best effort - } - setUser(null) - }, []) - - return ( - - {children} - - ) -} - -export function useAuth(): AuthContextValue { - const ctx = useContext(AuthContext) - if (!ctx) throw new Error('useAuth must be used within an AuthProvider') - return ctx -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/main.tsx b/.claude/worktrees/agent-aa2f0435/client/src/main.tsx deleted file mode 100644 index 53673805..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/main.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { AuthProvider } from './context/AuthContext' -import App from './App' - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: 1, - }, - }, -}) - -createRoot(document.getElementById('root')!).render( - - - - - - - - - , -) - -// Register service worker (web only, not Tauri) -if ('serviceWorker' in navigator && !('__TAURI__' in window)) { - window.addEventListener('load', () => { - navigator.serviceWorker - .register('/app/sw.js', { scope: '/app/' }) - .then((reg) => { - console.log('SW registered:', reg.scope) - }) - .catch((err) => { - console.warn('SW registration failed:', err) - }) - }) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/Browse.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/Browse.tsx deleted file mode 100644 index fc3c15aa..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/Browse.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { Search, Play, Film } from 'lucide-react' -import { api, imageUrl, type Series, type Movie } from '../api' - -function PosterCard({ - title, - year, - poster, - hasFile, - onClick, -}: { - title: string - year: number | null - poster: string | null - hasFile: boolean - onClick: () => void -}) { - return ( - - ) -} - -export default function Browse({ mode }: { mode: 'series' | 'movies' }) { - const navigate = useNavigate() - const [series, setSeries] = useState([]) - const [movies, setMovies] = useState([]) - const [loading, setLoading] = useState(true) - const [filter, setFilter] = useState('') - - useEffect(() => { - setLoading(true) - setFilter('') - if (mode === 'series') { - api.listSeries().then(setSeries).catch(() => setSeries([])).finally(() => setLoading(false)) - } else { - api.listMovies().then(setMovies).catch(() => setMovies([])).finally(() => setLoading(false)) - } - }, [mode]) - - const lf = filter.toLowerCase() - - const filteredSeries = series.filter((s) => s.title.toLowerCase().includes(lf)) - const filteredMovies = movies.filter((m) => m.title.toLowerCase().includes(lf)) - const count = mode === 'series' ? filteredSeries.length : filteredMovies.length - - return ( -
-
-
- - setFilter(e.target.value)} - placeholder={`Search ${mode}...`} - style={{ - width: '100%', padding: '10px 12px 10px 36px', - background: '#1e293b', border: '1px solid #334155', borderRadius: 8, - color: '#f1f5f9', fontSize: 14, outline: 'none', - }} - /> -
- {count} {mode} -
- - {loading ? ( -
Loading...
- ) : count === 0 ? ( -
- -

No {mode} found

-
- ) : ( -
- {mode === 'series' - ? filteredSeries.map((s) => ( - navigate(`/series/${s.id}`)} - /> - )) - : filteredMovies.map((m) => ( - navigate(`/movie/${m.id}`)} - /> - ))} -
- )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/DiscoverPage.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/DiscoverPage.tsx deleted file mode 100644 index 824849cb..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/DiscoverPage.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useState, useCallback } from 'react' -import { Search, Film, Tv } from 'lucide-react' -import { api, type DiscoverResult, type DiscoverSearchResults } from '../api' -import DiscoverGrid from '../components/DiscoverGrid' - -export default function DiscoverPage() { - const [query, setQuery] = useState('') - const [mediaType, setMediaType] = useState<'movie' | 'series'>('movie') - const [results, setResults] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const doSearch = useCallback( - async (q: string, type: 'movie' | 'series') => { - if (q.length < 2) return - setLoading(true) - setError(null) - try { - const data = await api.discoverSearch(q, type) - setResults(data) - } catch (e) { - setError(e instanceof Error ? e.message : 'Search failed') - } finally { - setLoading(false) - } - }, - [], - ) - - const handleSearch = (e: React.FormEvent) => { - e.preventDefault() - doSearch(query, mediaType) - } - - const handleTypeToggle = (type: 'movie' | 'series') => { - setMediaType(type) - if (query.length >= 2) { - doSearch(query, type) - } - } - - const handleRequest = async (item: DiscoverResult) => { - try { - await api.createRequest({ - mediaType: item.mediaType, - tmdbId: item.id, - title: item.title || item.name || 'Unknown', - year: parseInt( - (item.releaseDate || item.firstAirDate || '').substring(0, 4), - ) || undefined, - posterUrl: item.posterPath || undefined, - overview: item.overview || undefined, - }) - // Re-fetch to update status - if (query.length >= 2) { - doSearch(query, mediaType) - } - } catch (e) { - alert(e instanceof Error ? e.message : 'Failed to submit request') - } - } - - return ( -
-

- Discover & Request -

- -
-
- - -
-
- - setQuery(e.target.value)} - placeholder={`Search ${mediaType === 'series' ? 'TV shows' : 'movies'}...`} - style={{ - width: '100%', - padding: '8px 12px 8px 36px', - borderRadius: 8, - border: '1px solid #334155', - background: '#0f172a', - color: '#f1f5f9', - fontSize: 14, - outline: 'none', - }} - /> -
- -
- - {error && ( -
- {error} -
- )} - - {results && results.results.length === 0 && ( -
- No results found. Try a different search term. -
- )} - - {results && results.results.length > 0 && ( - <> -
- {results.totalResults} result{results.totalResults !== 1 ? 's' : ''} found -
- - - )} - - {!results && !loading && ( -
- -

Search for movies or TV shows to request them.

-
- )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/HomePage.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/HomePage.tsx deleted file mode 100644 index 656d5a63..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/HomePage.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useEffect, useState } from 'react' -import { api, type ContinueWatchingItem, type Series, type Movie, imageUrl } from '../api' -import MediaRow from '../components/MediaRow' - -export default function HomePage() { - const [continueItems, setContinueItems] = useState([]) - const [recentSeries, setRecentSeries] = useState([]) - const [recentMovies, setRecentMovies] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const load = async () => { - try { - const [cont, series, movies] = await Promise.all([ - api.getContinueWatching(20), - api.listSeries(), - api.listMovies(), - ]) - setContinueItems(cont) - - // Build "Recently Added" from series/movies lists (most recent first, limited) - const recentSeriesItems: ContinueWatchingItem[] = series - .slice(0, 20) - .map((s: Series) => ({ - id: 0, - userId: 0, - mediaFileId: 0, - mediaType: 'series', - mediaId: s.id, - episodeId: null, - positionSecs: 0, - durationSecs: 0, - completed: false, - updatedAt: '', - title: s.title, - posterUrl: imageUrl(s.images, 'poster'), - backdropUrl: imageUrl(s.images, 'fanart'), - episodeTitle: null, - seasonNumber: null, - episodeNumber: null, - year: s.year, - })) - setRecentSeries(recentSeriesItems) - - const recentMovieItems: ContinueWatchingItem[] = movies - .slice(0, 20) - .map((m: Movie) => ({ - id: 0, - userId: 0, - mediaFileId: m.movieFileId || 0, - mediaType: 'movie', - mediaId: m.id, - episodeId: null, - positionSecs: 0, - durationSecs: 0, - completed: false, - updatedAt: '', - title: m.title, - posterUrl: imageUrl(m.images, 'poster'), - backdropUrl: imageUrl(m.images, 'fanart'), - episodeTitle: null, - seasonNumber: null, - episodeNumber: null, - year: m.year, - })) - setRecentMovies(recentMovieItems) - } catch (e) { - console.error('Failed to load home page data:', e) - } finally { - setLoading(false) - } - } - load() - }, []) - - if (loading) { - return ( -
- Loading... -
- ) - } - - const hasContent = continueItems.length > 0 || recentSeries.length > 0 || recentMovies.length > 0 - - return ( -
- {!hasContent && ( -
- No media available. Add some series or movies to get started. -
- )} - - - - -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/LoginPage.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/LoginPage.tsx deleted file mode 100644 index bfd29398..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/LoginPage.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '../context/AuthContext' - -export default function LoginPage({ onSwitchToRegister }: { onSwitchToRegister: () => void }) { - const { login } = useAuth() - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (!username.trim() || !password) { - setError('Please enter both username and password') - return - } - setError(null) - setLoading(true) - try { - await login(username.trim(), password) - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed') - } finally { - setLoading(false) - } - } - - return ( -
-
-

- StackArr -

-

- Sign in to your account -

- - - setUsername(e.target.value)} - placeholder="username" - autoComplete="username" - style={inputStyle} - /> - - - setPassword(e.target.value)} - placeholder="password" - autoComplete="current-password" - style={inputStyle} - /> - - - - {error && ( -

{error}

- )} - -

- Have an invite code?{' '} - -

-
-
- ) -} - -const labelStyle: React.CSSProperties = { - display: 'block', color: '#94a3b8', fontSize: 12, - fontWeight: 500, marginBottom: 4, -} - -const inputStyle: React.CSSProperties = { - width: '100%', padding: '10px 12px', borderRadius: 6, - border: '1px solid #475569', background: '#0f172a', - color: '#e2e8f0', fontSize: 14, marginBottom: 12, - boxSizing: 'border-box', -} - -const buttonStyle: React.CSSProperties = { - width: '100%', padding: '12px 16px', borderRadius: 8, - border: 'none', background: '#3b82f6', color: '#fff', - fontSize: 15, fontWeight: 600, cursor: 'pointer', -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/MovieView.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/MovieView.tsx deleted file mode 100644 index f563457b..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/MovieView.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useState, useEffect } from 'react' -import { useParams, useNavigate } from 'react-router-dom' -import { ArrowLeft, Play, Film } from 'lucide-react' -import { api, imageUrl, type Movie } from '../api' -import WatchlistButton from '../components/WatchlistButton' -import RatingStars from '../components/RatingStars' - -export default function MovieView() { - const { id } = useParams<{ id: string }>() - const navigate = useNavigate() - const [movie, setMovie] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (!id) return - setLoading(true) - api.getMovie(Number(id)) - .then(setMovie) - .catch(() => {}) - .finally(() => setLoading(false)) - }, [id]) - - if (loading) return
Loading...
- if (!movie) return
Movie not found
- - const fanart = imageUrl(movie.images, 'fanart') - const poster = imageUrl(movie.images, 'poster') - - return ( -
- - - {/* Hero */} -
- {fanart && ( - - )} -
- {poster ? ( - {movie.title} - ) : ( -
- -
- )} -
-

{movie.title}

-
- {movie.year && {movie.year}} - {movie.studio && {movie.studio}} -
- {movie.overview && ( -

- {movie.overview.length > 400 ? movie.overview.slice(0, 400) + '...' : movie.overview} -

- )} - -
- - -
- - {movie.movieFileId != null ? ( -
- -
- ) : ( -
- No file available -
- )} -
-
-
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/Player.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/Player.tsx deleted file mode 100644 index d8d865ea..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/Player.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react' -import { useParams, useNavigate } from 'react-router-dom' -import { ArrowLeft } from 'lucide-react' -import Hls from 'hls.js' -import { api, type StreamInfo, type WatchProgress } from '../api' -import ProgressReporter from '../components/ProgressReporter' - -const DIRECT_CONTAINERS = ['mp4', 'mov', 'webm'] -const DIRECT_VIDEO_CODECS = ['h264'] -const DIRECT_AUDIO_CODECS = ['aac', 'mp3', 'opus', 'vorbis', 'flac'] - -function canDirectPlay(info: StreamInfo): boolean { - const video = info.videoStreams[0] - const audio = info.audioStreams[0] - if (!video) return false - const containerOk = info.container.split(',').some((c) => DIRECT_CONTAINERS.includes(c.trim())) - if (!containerOk) return false - if (!DIRECT_VIDEO_CODECS.includes(video.codec)) return false - if (audio && !DIRECT_AUDIO_CODECS.includes(audio.codec)) return false - if (video.isHdr) return false - return true -} - -function formatDuration(secs: number): string { - const h = Math.floor(secs / 3600) - const m = Math.floor((secs % 3600) / 60) - const s = Math.floor(secs % 60) - if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` - return `${m}:${s.toString().padStart(2, '0')}` -} - -function formatBitrate(bps: number): string { - if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(1)} Mbps` - if (bps >= 1_000) return `${(bps / 1_000).toFixed(0)} kbps` - return `${bps} bps` -} - -function channelLayout(ch: number): string { - switch (ch) { - case 1: return 'Mono' - case 2: return 'Stereo' - case 6: return '5.1' - case 8: return '7.1' - default: return `${ch}ch` - } -} - -type Mode = 'loading' | 'direct' | 'transcode' | 'error' - -export default function Player() { - const { fileId } = useParams<{ fileId: string }>() - const navigate = useNavigate() - const videoRef = useRef(null) - const hlsRef = useRef(null) - - const [info, setInfo] = useState(null) - const [mode, setMode] = useState('loading') - const [sessionId, setSessionId] = useState(null) - const [error, setError] = useState(null) - const [selectedAudio, setSelectedAudio] = useState(0) - const [selectedSub, setSelectedSub] = useState(null) - const [savedProgress, setSavedProgress] = useState(null) - const [showResume, setShowResume] = useState(false) - - const id = Number(fileId) - - // Fetch saved progress - useEffect(() => { - if (!id || isNaN(id)) return - api.getProgressSafe(id).then((p) => { - if (p && p.positionSecs > 5 && !p.completed) { - setSavedProgress(p) - setShowResume(true) - } - }) - }, [id]) - - // Load stream info - useEffect(() => { - if (!id || isNaN(id)) { - setError('Invalid media file ID') - setMode('error') - return - } - api.streamInfo(id) - .then((data) => { - setInfo(data) - if (canDirectPlay(data)) { - setMode('direct') - } else { - setMode('transcode') - } - }) - .catch((e) => { - setError(`Failed to load media info: ${e.message}`) - setMode('error') - }) - }, [id]) - - // Direct play - useEffect(() => { - if (mode !== 'direct' || !videoRef.current) return - videoRef.current.src = api.directPlayUrl(id) - }, [mode, id]) - - // HLS transcode - useEffect(() => { - if (mode !== 'transcode' || !info) return - - api.startTranscode(id, { - videoStreamIndex: 0, - audioStreamIndex: selectedAudio, - subtitleStreamIndex: selectedSub ?? undefined, - }) - .then((resp) => { - setSessionId(resp.sessionId) - if (!videoRef.current) return - - const playlistUrl = `/api/v1${resp.playlistUrl}` - - if (Hls.isSupported()) { - const hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }) - hls.loadSource(playlistUrl) - hls.attachMedia(videoRef.current) - hls.on(Hls.Events.MANIFEST_PARSED, () => { - void videoRef.current?.play() - }) - hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - setError(`HLS error: ${data.details}`) - setMode('error') - } - }) - hlsRef.current = hls - } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) { - videoRef.current.src = playlistUrl - void videoRef.current.play() - } else { - setError('Browser does not support HLS playback') - setMode('error') - } - }) - .catch((e) => { - setError(`Failed to start transcode: ${e.message}`) - setMode('error') - }) - - return () => { - if (hlsRef.current) { - hlsRef.current.destroy() - hlsRef.current = null - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, id, selectedAudio, selectedSub]) - - // Cleanup session on unmount - useEffect(() => { - return () => { - if (sessionId) { - fetch(`/api/v1/stream/sessions/${sessionId}`, { method: 'DELETE' }).catch(() => {}) - } - } - }, [sessionId]) - - const forceTranscode = useCallback(() => { - if (videoRef.current) { - videoRef.current.pause() - videoRef.current.removeAttribute('src') - } - setMode('transcode') - }, []) - - const handleResume = useCallback(() => { - if (savedProgress && videoRef.current) { - videoRef.current.currentTime = savedProgress.positionSecs - } - setShowResume(false) - }, [savedProgress]) - - const handleStartOver = useCallback(() => { - setShowResume(false) - }, []) - - return ( -
- - - {/* Player */} -
- {mode === 'loading' ? ( -
- Analyzing media... -
- ) : mode === 'error' ? ( -
- {error || 'Playback error'} -
- ) : ( - <> - - - {/* Mode badge */} -
- {mode === 'direct' ? 'Direct Play' : 'Transcoding'} -
- - {/* Resume prompt */} - {showResume && savedProgress && ( -
- - Resume from {formatDuration(savedProgress.positionSecs)}? - - - -
- )} - - - - )} -
- - {/* Controls */} - {info && mode !== 'error' && mode !== 'loading' && ( -
- {info.audioStreams.length > 1 && ( - - )} - - {info.subtitleStreams.length > 0 && ( - - )} - - {mode === 'direct' && ( - - )} -
- )} - - {/* Media info */} - {info && ( -
-

- Media Info -

-
- Container: {info.container} - Duration: {formatDuration(info.durationSecs)} - Bitrate: {formatBitrate(info.bitrate)} - {info.videoStreams[0] && ( - <> - - Video: {info.videoStreams[0].codec.toUpperCase()}{' '} - {info.videoStreams[0].width}x{info.videoStreams[0].height} - {info.videoStreams[0].isHdr ? ' HDR' : ''} - - Frame Rate: {info.videoStreams[0].frameRate.toFixed(2)} fps - - )} - {info.audioStreams[0] && ( - - Audio: {info.audioStreams[0].codec.toUpperCase()}{' '} - {channelLayout(info.audioStreams[0].channels)} - - )} -
-
- )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/RegisterPage.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/RegisterPage.tsx deleted file mode 100644 index e79ebb79..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/RegisterPage.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '../context/AuthContext' - -export default function RegisterPage({ - onSwitchToLogin, - inviteCode: prefilledInviteCode, -}: { - onSwitchToLogin: () => void - inviteCode?: string | null -}) { - const { register } = useAuth() - const [username, setUsername] = useState('') - const [displayName, setDisplayName] = useState('') - const [password, setPassword] = useState('') - const [inviteCode, setInviteCode] = useState(prefilledInviteCode ?? '') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (!username.trim() || !password || !inviteCode.trim()) { - setError('Please fill in all required fields') - return - } - if (password.length < 6) { - setError('Password must be at least 6 characters') - return - } - setError(null) - setLoading(true) - try { - await register( - username.trim(), - password, - displayName.trim() || username.trim(), - inviteCode.trim(), - ) - } catch (err) { - setError(err instanceof Error ? err.message : 'Registration failed') - } finally { - setLoading(false) - } - } - - return ( -
-
-

- StackArr -

-

- Create your account -

- - - {prefilledInviteCode ? ( - <> - -

- Your invite code has been verified via server discovery -

- - ) : ( - setInviteCode(e.target.value.toUpperCase())} - placeholder="ABCD1234" - maxLength={8} - style={{ ...inputStyle, letterSpacing: 2, textAlign: 'center' }} - /> - )} - - - setUsername(e.target.value)} - placeholder="username" - autoComplete="username" - style={inputStyle} - /> - - - setDisplayName(e.target.value)} - placeholder="Your name (optional)" - style={inputStyle} - /> - - - setPassword(e.target.value)} - placeholder="minimum 6 characters" - autoComplete="new-password" - style={inputStyle} - /> - - - - {error && ( -

{error}

- )} - -

- Already have an account?{' '} - -

-
-
- ) -} - -const labelStyle: React.CSSProperties = { - display: 'block', color: '#94a3b8', fontSize: 12, - fontWeight: 500, marginBottom: 4, -} - -const inputStyle: React.CSSProperties = { - width: '100%', padding: '10px 12px', borderRadius: 6, - border: '1px solid #475569', background: '#0f172a', - color: '#e2e8f0', fontSize: 14, marginBottom: 12, - boxSizing: 'border-box', -} - -const buttonStyle: React.CSSProperties = { - width: '100%', padding: '12px 16px', borderRadius: 8, - border: 'none', background: '#3b82f6', color: '#fff', - fontSize: 15, fontWeight: 600, cursor: 'pointer', -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/RequestsPage.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/RequestsPage.tsx deleted file mode 100644 index a3bd137f..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/RequestsPage.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useState, useEffect } from 'react' -import { Loader2 } from 'lucide-react' -import { api, type MediaRequest } from '../api' -import RequestCard from '../components/RequestCard' - -export default function RequestsPage() { - const [requests, setRequests] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - loadRequests() - }, []) - - async function loadRequests() { - setLoading(true) - setError(null) - try { - const data = await api.listMyRequests() - setRequests(data) - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load requests') - } finally { - setLoading(false) - } - } - - if (loading) { - return ( -
- - -
- ) - } - - return ( -
-

- My Requests -

- - {error && ( -
- {error} -
- )} - - {requests.length === 0 ? ( -
-

You have no requests yet.

-

- Use the Discover page to search and request movies or TV shows. -

-
- ) : ( -
- {requests.map((req) => ( - - ))} -
- )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/SeriesView.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/SeriesView.tsx deleted file mode 100644 index 1f6bcc98..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/SeriesView.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, useEffect } from 'react' -import { useParams, useNavigate } from 'react-router-dom' -import { ArrowLeft, Play, ChevronDown, ChevronRight } from 'lucide-react' -import { api, imageUrl, type Series, type Episode } from '../api' -import WatchlistButton from '../components/WatchlistButton' -import RatingStars from '../components/RatingStars' - -export default function SeriesView() { - const { id } = useParams<{ id: string }>() - const navigate = useNavigate() - const [series, setSeries] = useState(null) - const [episodes, setEpisodes] = useState([]) - const [loading, setLoading] = useState(true) - const [expandedSeasons, setExpandedSeasons] = useState>(new Set()) - - useEffect(() => { - if (!id) return - setLoading(true) - Promise.all([api.getSeries(Number(id)), api.getEpisodes(Number(id))]) - .then(([s, eps]) => { - setSeries(s) - setEpisodes(eps) - const seasonsWithFiles = new Set( - eps.filter((e) => e.episodeFileId != null).map((e) => e.seasonNumber), - ) - setExpandedSeasons(seasonsWithFiles) - }) - .catch(() => {}) - .finally(() => setLoading(false)) - }, [id]) - - if (loading) return
Loading...
- if (!series) return
Series not found
- - const seasons = [...new Set(episodes.map((e) => e.seasonNumber))].sort((a, b) => a - b) - const fanart = imageUrl(series.images, 'fanart') - - const toggleSeason = (s: number) => { - const next = new Set(expandedSeasons) - next.has(s) ? next.delete(s) : next.add(s) - setExpandedSeasons(next) - } - - return ( -
- - - {/* Hero banner */} -
- {fanart && ( - - )} -
-

{series.title}

-
- {series.year && {series.year}} - {seasons.length} Seasons - {episodes.filter((e) => e.episodeFileId != null).length} / {episodes.length} Episodes - {series.status} -
- {series.overview && ( -

- {series.overview.length > 300 ? series.overview.slice(0, 300) + '...' : series.overview} -

- )} -
- - -
-
-
- - {/* Season accordion */} -
- {seasons.map((seasonNum) => { - const seasonEps = episodes - .filter((e) => e.seasonNumber === seasonNum) - .sort((a, b) => a.episodeNumber - b.episodeNumber) - const expanded = expandedSeasons.has(seasonNum) - const fileCount = seasonEps.filter((e) => e.episodeFileId != null).length - - return ( -
- - - {expanded && ( -
- {seasonEps.map((ep) => ( -
-
- - {ep.episodeNumber} - - - {ep.title || 'TBA'} - -
- - {ep.episodeFileId != null && ( - - )} -
- ))} -
- )} -
- ) - })} -
-
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/ServerConnect.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/ServerConnect.tsx deleted file mode 100644 index bd2c4cd4..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/ServerConnect.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useState } from 'react' -import { redeemClaimCode, saveConnection, type ServerConnection } from '../api' - -const DEFAULT_BOOTSTRAP = 'https://streambootstrap.indexarr.net' - -interface ConnectedOpts { - claimType?: string - inviteCode?: string -} - -export default function ServerConnect({ onConnected }: { onConnected: (opts?: ConnectedOpts) => void }) { - const [mode, setMode] = useState<'claim' | 'login' | 'direct'>('claim') - const [code, setCode] = useState('') - const [bootstrapUrl, setBootstrapUrl] = useState(DEFAULT_BOOTSTRAP) - const [directUrl, setDirectUrl] = useState('') - const [directToken, setDirectToken] = useState('') - const [serverName, setServerName] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - - async function handleClaim() { - if (!code.trim()) { - setError('Please enter an invite code') - return - } - setError(null) - setLoading(true) - try { - const result = await redeemClaimCode(code.trim(), bootstrapUrl) - onConnected({ claimType: result.claimType, inviteCode: result.inviteCode }) - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to connect') - } finally { - setLoading(false) - } - } - - async function handleLogin() { - if (!serverName.trim() || !username.trim() || !password) { - setError('Please enter server name, username, and password') - return - } - setError(null) - setLoading(true) - try { - // Resolve server name via bootstrap - const lookupRes = await fetch( - `${bootstrapUrl}/api/v1/servers/by-name/${encodeURIComponent(serverName.trim())}`, - { signal: AbortSignal.timeout(5000) }, - ) - if (lookupRes.status === 404) throw new Error('Server not found') - if (lookupRes.status === 503) throw new Error('Server is currently offline') - if (!lookupRes.ok) throw new Error(`Bootstrap error: ${lookupRes.status}`) - const data = await lookupRes.json() - - // Probe server IPs (local first, then public) - const urls = [ - ...data.localIps.map((ip: string) => `http://${ip}:${data.port}`), - `http://${data.publicIp}:${data.port}`, - ] - - let serverUrl: string | null = null - for (const url of urls) { - try { - const probe = await fetch(`${url}/api/v1/system/status`, { - signal: AbortSignal.timeout(3000), - }) - if (probe.ok) { - serverUrl = url - break - } - } catch { /* try next */ } - } - - if (!serverUrl) throw new Error('Server found but unreachable. Check your network/firewall.') - - // Authenticate with the server - const loginRes = await fetch(`${serverUrl}/api/v1/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ username: username.trim(), password }), - }) - if (loginRes.status === 401) throw new Error('Invalid username or password') - if (!loginRes.ok) throw new Error(`Login failed: ${loginRes.status}`) - - const conn: ServerConnection = { - serverUrl, - serverName: data.serverName || serverName.trim(), - serverId: data.serverId || '', - } - saveConnection(conn) - onConnected() - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to connect') - } finally { - setLoading(false) - } - } - - async function handleDirect() { - if (!directUrl.trim() || !directToken.trim()) { - setError('Please enter both the server URL and token') - return - } - setError(null) - setLoading(true) - try { - const url = directUrl.trim().replace(/\/$/, '') - const res = await fetch(`${url}/api/v1/system/status`, { - headers: { Authorization: `Bearer ${directToken.trim()}` }, - signal: AbortSignal.timeout(5000), - }) - if (!res.ok) throw new Error(`Server returned ${res.status}`) - const status = await res.json() - const conn: ServerConnection = { - serverUrl: url, - serverName: status.instanceName || 'StackArr', - serverId: '', - clientToken: directToken.trim(), - } - saveConnection(conn) - onConnected() - } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to connect') - } finally { - setLoading(false) - } - } - - return ( -
-
-

- StackArr Player -

-

- Connect to your StackArr server -

- - {/* Mode toggle */} -
- {(['claim', 'login', 'direct'] as const).map((m) => ( - - ))} -
- - {mode === 'claim' ? ( - <> - - setCode(e.target.value.toUpperCase())} - placeholder="ABC12DEF" - maxLength={8} - style={{ ...inputStyle, fontSize: 24, letterSpacing: 4, textAlign: 'center' }} - /> -
- - Advanced - - - setBootstrapUrl(e.target.value)} - style={inputStyle} - /> -
- - - ) : mode === 'login' ? ( - <> - - setServerName(e.target.value)} - placeholder="e.g. MyStackArr" - style={inputStyle} - /> - - setUsername(e.target.value)} - placeholder="username" - autoComplete="username" - style={inputStyle} - /> - - setPassword(e.target.value)} - placeholder="password" - autoComplete="current-password" - style={inputStyle} - /> -
- - Advanced - - - setBootstrapUrl(e.target.value)} - style={inputStyle} - /> -
- - - ) : ( - <> - - setDirectUrl(e.target.value)} - placeholder="http://192.168.1.100:8989" - style={inputStyle} - /> - - setDirectToken(e.target.value)} - placeholder="Your API key or token" - type="password" - style={inputStyle} - /> - - - )} - - {error && ( -

{error}

- )} -
-
- ) -} - -const labelStyle: React.CSSProperties = { - display: 'block', color: '#94a3b8', fontSize: 12, - fontWeight: 500, marginBottom: 4, -} - -const inputStyle: React.CSSProperties = { - width: '100%', padding: '10px 12px', borderRadius: 6, - border: '1px solid #475569', background: '#0f172a', - color: '#e2e8f0', fontSize: 14, marginBottom: 12, - boxSizing: 'border-box', -} - -const buttonStyle: React.CSSProperties = { - width: '100%', padding: '12px 16px', borderRadius: 8, - border: 'none', background: '#3b82f6', color: '#fff', - fontSize: 15, fontWeight: 600, cursor: 'pointer', -} diff --git a/.claude/worktrees/agent-aa2f0435/client/src/pages/WatchlistPage.tsx b/.claude/worktrees/agent-aa2f0435/client/src/pages/WatchlistPage.tsx deleted file mode 100644 index 22889bbb..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/src/pages/WatchlistPage.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { Bookmark, X, Tv, Film } from 'lucide-react' -import { api, type WatchlistItem } from '../api' - -type FilterTab = 'all' | 'series' | 'movie' - -export default function WatchlistPage() { - const navigate = useNavigate() - const [items, setItems] = useState([]) - const [loading, setLoading] = useState(true) - const [filter, setFilter] = useState('all') - - const load = () => { - setLoading(true) - api - .getWatchlist(filter === 'all' ? undefined : filter) - .then(setItems) - .catch(() => {}) - .finally(() => setLoading(false)) - } - - useEffect(() => { - load() - }, [filter]) - - const remove = async (mediaType: string, mediaId: number) => { - try { - await api.removeFromWatchlist(mediaType, mediaId) - setItems((prev) => prev.filter((i) => !(i.mediaType === mediaType && i.mediaId === mediaId))) - } catch (e) { - console.error('Failed to remove from watchlist:', e) - } - } - - const tabStyle = (active: boolean) => ({ - padding: '8px 16px', - borderRadius: 8, - fontSize: 13, - fontWeight: 500 as const, - border: 'none', - cursor: 'pointer' as const, - color: active ? '#fff' : '#94a3b8', - background: active ? '#1e40af' : '#1e293b', - transition: 'all 0.15s', - }) - - return ( -
-
-

- My Watchlist -

-
- - - -
-
- - {loading ? ( -
Loading...
- ) : items.length === 0 ? ( -
- Your watchlist is empty. Browse your library and bookmark items to watch later. -
- ) : ( -
- {items.map((item) => ( -
- navigate(item.mediaType === 'series' ? `/series/${item.mediaId}` : `/movie/${item.mediaId}`) - } - > - {item.posterUrl ? ( - {item.title - ) : ( -
- {item.mediaType === 'series' ? : } -
- )} - -
-
- {item.title || 'Unknown'} -
-
- {item.mediaType} - {item.year && {item.year}} -
-
- - {/* Remove button */} - -
- ))} -
- )} -
- ) -} diff --git a/.claude/worktrees/agent-aa2f0435/client/tsconfig.app.json b/.claude/worktrees/agent-aa2f0435/client/tsconfig.app.json deleted file mode 100644 index af516fcc..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/tsconfig.app.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2023", - "useDefineForClassFields": true, - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "module": "ESNext", - "types": ["vite/client"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/.claude/worktrees/agent-aa2f0435/client/tsconfig.json b/.claude/worktrees/agent-aa2f0435/client/tsconfig.json deleted file mode 100644 index 1ffef600..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/.claude/worktrees/agent-aa2f0435/client/tsconfig.node.json b/.claude/worktrees/agent-aa2f0435/client/tsconfig.node.json deleted file mode 100644 index 8a67f62f..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/tsconfig.node.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "types": ["node"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/.claude/worktrees/agent-aa2f0435/client/vite.config.ts b/.claude/worktrees/agent-aa2f0435/client/vite.config.ts deleted file mode 100644 index ba24a7a5..00000000 --- a/.claude/worktrees/agent-aa2f0435/client/vite.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -export default defineConfig({ - plugins: [react()], - base: '/app/', - server: { - port: 3001, - proxy: { - '/api': { - target: 'http://192.168.0.30:9111', - changeOrigin: true, - }, - }, - }, -}) diff --git a/.claude/worktrees/agent-aa2f0435/config.example.toml b/.claude/worktrees/agent-aa2f0435/config.example.toml deleted file mode 100644 index 1066d08a..00000000 --- a/.claude/worktrees/agent-aa2f0435/config.example.toml +++ /dev/null @@ -1,120 +0,0 @@ -# StackArr Configuration -# Copy this file to /config/stackarr.toml and adjust as needed. -# All values shown are the defaults. - -[general] -# Display name for this instance -instance_name = "StackArr" -# Address the HTTP server binds to -bind_addr = "0.0.0.0" -# Port the HTTP server listens on -port = 8989 -# Directory for config and internal data -data_dir = "/config" -# Log level: trace, debug, info, warn, error -log_level = "info" - -[database] -# PostgreSQL connection string -url = "postgresql://stackarr:stackarr@localhost:5432/stackarr" -# Maximum number of connections in the pool -max_connections = 20 - -[auth] -# Authentication method: "forms", "basic", "none" -method = "forms" -# Optional API key for external access (leave unset to disable) -# api_key = "your-api-key-here" - -[torrent] -# Enable the embedded torrent client -enabled = false -# Directory for in-progress torrent downloads -# download_dir = "/downloads/torrents/incomplete" -# Directory for completed torrent downloads -# complete_dir = "/downloads/torrents/complete" -# BitTorrent listen port -listen_port = 6881 -# Enable DHT for peer discovery -dht_enabled = true -# Maximum number of peers per torrent -peer_limit = 200 -# Upload speed limit in bytes/sec (0 = unlimited) -upload_limit_bps = 0 -# Download speed limit in bytes/sec (0 = unlimited) -download_limit_bps = 0 - -[usenet] -# Enable the embedded usenet downloader -enabled = false -# Directory for in-progress NZB downloads -# incomplete_dir = "/downloads/usenet/incomplete" -# Directory for completed NZB downloads -# complete_dir = "/downloads/usenet/complete" -# Maximum concurrent article downloads -max_active_downloads = 3 - -# Usenet server example (uncomment and duplicate as needed): -# [[usenet.servers]] -# name = "Primary" -# host = "news.example.com" -# port = 563 -# ssl = true -# username = "user" -# password = "pass" -# connections = 8 -# priority = 0 - -[indexarr] -# Enable Indexarr sidecar integration -enabled = false -# Indexarr instance URL -url = "http://indexarr:8080" -# API key for Indexarr -# api_key = "your-indexarr-api-key" -# Mode: "peer" or "standalone" -mode = "peer" - -[streaming] -# Enable the built-in streaming server (Plex-like media playback) -enabled = false -# Directory for temporary transcode segments -# transcode_dir = "/config/transcode" -# Path to ffmpeg binary -ffmpeg_path = "ffmpeg" -# Path to ffprobe binary -ffprobe_path = "ffprobe" -# HLS segment duration in seconds -segment_duration_secs = 6 -# Maximum concurrent transcode sessions -max_concurrent_sessions = 3 - -[streaming.hwaccel] -# Enable hardware-accelerated transcoding -enabled = false -# Acceleration type: "qsv", "vaapi", "nvenc" -accel_type = "qsv" -# Device path (e.g. /dev/dri/renderD128 for QSV/VAAPI) -# device = "/dev/dri/renderD128" - -[naming] - -[naming.series] -# Whether to rename episode files on import -rename = true -# Standard episode format -standard = "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]" -# Daily episode format -daily = "{Series Title} - {Air-Date} - {Episode Title} [{Quality Title}]" -# Anime episode format -anime = "{Series Title} - S{season:00}E{episode:00} - {Absolute Episode} - {Episode Title} [{Quality Title}]" -# Season folder format -season_folder = "Season {season:00}" - -[naming.movie] -# Whether to rename movie files on import -rename = true -# Movie file format -standard = "{Movie Title} ({Release Year}) [{Quality Title}]" -# Movie folder format -folder = "{Movie Title} ({Release Year})" diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/Cargo.toml deleted file mode 100644 index 06099cdb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "stackarr-bootstrap" -version.workspace = true -edition.workspace = true -license.workspace = true - -[[bin]] -name = "stackarr-bootstrap" -path = "src/main.rs" - -[lints] -workspace = true - -[dependencies] -axum = { workspace = true } -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -dashmap = { workspace = true } -uuid = { workspace = true } -toml = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -clap = { version = "4", features = ["derive", "env"] } -rand = { workspace = true } -anyhow = { workspace = true } -chrono = { workspace = true } -tower-http = { workspace = true } -rusqlite = { version = "0.32", features = ["bundled"] } -reqwest = { workspace = true } -sha2 = "0.10" -bip39 = "2" diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/bootstrap.example.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/bootstrap.example.toml deleted file mode 100644 index 5211ceb4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/bootstrap.example.toml +++ /dev/null @@ -1,6 +0,0 @@ -[bootstrap] -bind_addr = "0.0.0.0" -port = 7890 -bootstrap_token = "change-me-to-a-long-random-secret" -registration_ttl_secs = 120 -claim_ttl_secs = 240 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/config.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/config.rs deleted file mode 100644 index d51498aa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/config.rs +++ /dev/null @@ -1,41 +0,0 @@ -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct Config { - pub bootstrap: BootstrapSection, -} - -#[derive(Debug, Deserialize)] -pub struct BootstrapSection { - #[serde(default = "default_bind_addr")] - pub bind_addr: String, - #[serde(default = "default_port")] - pub port: u16, - pub bootstrap_token: String, - #[serde(default = "default_registration_ttl")] - pub registration_ttl_secs: u64, - #[serde(default = "default_claim_ttl")] - pub claim_ttl_secs: u64, - #[serde(default = "default_database_path")] - pub database_path: String, -} - -fn default_bind_addr() -> String { - "0.0.0.0".to_string() -} - -fn default_port() -> u16 { - 7890 -} - -fn default_registration_ttl() -> u64 { - 120 -} - -fn default_claim_ttl() -> u64 { - 240 -} - -fn default_database_path() -> String { - "/config/bootstrap.db".to_string() -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/db.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/db.rs deleted file mode 100644 index d017489f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/db.rs +++ /dev/null @@ -1,270 +0,0 @@ -use std::sync::Arc; - -use rusqlite::Connection; -use tokio::sync::Mutex; - -pub struct BootstrapDb { - conn: Arc>, -} - -pub enum UpsertResult { - Created, - Updated, - Conflict(String), -} - -#[allow(dead_code)] -pub struct ClaimRow { - pub code: String, - pub server_id: String, - pub claim_type: String, - pub invite_code: Option, -} - -impl BootstrapDb { - pub fn new(path: &str) -> anyhow::Result { - let conn = Connection::open(path)?; - - // Enable WAL mode for better concurrent read performance - conn.execute_batch("PRAGMA journal_mode=WAL;")?; - - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS server_names ( - server_name TEXT NOT NULL PRIMARY KEY COLLATE NOCASE, - server_id TEXT NOT NULL UNIQUE, - recovery_key_hash TEXT, - registered_at TEXT NOT NULL DEFAULT (datetime('now')), - last_heartbeat TEXT NOT NULL DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS pending_claims ( - code TEXT NOT NULL PRIMARY KEY, - server_id TEXT NOT NULL, - claim_type TEXT NOT NULL DEFAULT 'device', - invite_code TEXT, - expires_at TEXT NOT NULL - );", - )?; - - Ok(Self { - conn: Arc::new(Mutex::new(conn)), - }) - } - - pub async fn upsert_server_name( - &self, - server_name: &str, - server_id: &str, - ) -> anyhow::Result { - let conn = self.conn.clone(); - let server_name = server_name.to_string(); - let server_id = server_id.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - - // Check if the name already exists - let existing: Option = conn - .query_row( - "SELECT server_id FROM server_names WHERE server_name = ?1", - [&server_name], - |row| row.get(0), - ) - .ok(); - - match existing { - Some(ref existing_id) if existing_id == &server_id => { - // Same server, update heartbeat - conn.execute( - "UPDATE server_names SET last_heartbeat = datetime('now') WHERE server_name = ?1", - [&server_name], - )?; - Ok(UpsertResult::Updated) - } - Some(existing_id) => { - // Different server owns this name - Ok(UpsertResult::Conflict(existing_id)) - } - None => { - // New registration - conn.execute( - "INSERT INTO server_names (server_name, server_id) VALUES (?1, ?2)", - [&server_name, &server_id], - )?; - Ok(UpsertResult::Created) - } - } - }) - .await? - } - - #[allow(dead_code)] - pub async fn update_heartbeat(&self, server_id: &str) -> anyhow::Result<()> { - let conn = self.conn.clone(); - let server_id = server_id.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - conn.execute( - "UPDATE server_names SET last_heartbeat = datetime('now') WHERE server_id = ?1", - [&server_id], - )?; - Ok(()) - }) - .await? - } - - #[allow(dead_code)] - pub async fn lookup_by_name(&self, name: &str) -> anyhow::Result> { - let conn = self.conn.clone(); - let name = name.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - let result = conn - .query_row( - "SELECT server_id FROM server_names WHERE server_name = ?1", - [&name], - |row| row.get(0), - ) - .ok(); - Ok(result) - }) - .await? - } - - pub async fn insert_claim( - &self, - code: &str, - server_id: &str, - claim_type: &str, - invite_code: Option<&str>, - expires_at: &str, - ) -> anyhow::Result<()> { - let conn = self.conn.clone(); - let code = code.to_string(); - let server_id = server_id.to_string(); - let claim_type = claim_type.to_string(); - let invite_code = invite_code.map(String::from); - let expires_at = expires_at.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - conn.execute( - "INSERT INTO pending_claims (code, server_id, claim_type, invite_code, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)", - rusqlite::params![code, server_id, claim_type, invite_code, expires_at], - )?; - Ok(()) - }) - .await? - } - - pub async fn take_claim(&self, code: &str) -> anyhow::Result> { - let conn = self.conn.clone(); - let code = code.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - - let row = conn - .query_row( - "SELECT code, server_id, claim_type, invite_code FROM pending_claims WHERE code = ?1", - [&code], - |row| { - Ok(ClaimRow { - code: row.get(0)?, - server_id: row.get(1)?, - claim_type: row.get(2)?, - invite_code: row.get(3)?, - }) - }, - ) - .ok(); - - if row.is_some() { - conn.execute("DELETE FROM pending_claims WHERE code = ?1", [&code])?; - } - - Ok(row) - }) - .await? - } - - pub async fn sweep_expired_claims(&self) -> anyhow::Result { - let conn = self.conn.clone(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - let count = conn.execute( - "DELETE FROM pending_claims WHERE expires_at < datetime('now')", - [], - )?; - Ok(count) - }) - .await? - } - - #[allow(dead_code)] - pub async fn set_recovery_key_hash( - &self, - server_name: &str, - hash: &str, - ) -> anyhow::Result<()> { - let conn = self.conn.clone(); - let server_name = server_name.to_string(); - let hash = hash.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - conn.execute( - "UPDATE server_names SET recovery_key_hash = ?1 WHERE server_name = ?2", - [&hash, &server_name], - )?; - Ok(()) - }) - .await? - } - - #[allow(dead_code)] - pub async fn get_recovery_key_hash( - &self, - server_name: &str, - ) -> anyhow::Result> { - let conn = self.conn.clone(); - let server_name = server_name.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - let result = conn - .query_row( - "SELECT recovery_key_hash FROM server_names WHERE server_name = ?1", - [&server_name], - |row| row.get(0), - ) - .ok(); - Ok(result) - }) - .await? - } - - #[allow(dead_code)] - pub async fn transfer_server_name( - &self, - server_name: &str, - new_server_id: &str, - ) -> anyhow::Result<()> { - let conn = self.conn.clone(); - let server_name = server_name.to_string(); - let new_server_id = new_server_id.to_string(); - - tokio::task::spawn_blocking(move || { - let conn = conn.blocking_lock(); - conn.execute( - "UPDATE server_names SET server_id = ?1, last_heartbeat = datetime('now') WHERE server_name = ?2", - [&new_server_id, &server_name], - )?; - Ok(()) - }) - .await? - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/main.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/main.rs deleted file mode 100644 index 1278f75a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/main.rs +++ /dev/null @@ -1,122 +0,0 @@ -mod config; -mod db; -mod routes; -mod state; - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use axum::routing::{delete, get, post}; -use axum::Router; -use clap::Parser; -use tower_http::cors::{Any, CorsLayer}; -use tracing_subscriber::EnvFilter; - -use config::Config; -use db::BootstrapDb; -use state::BootstrapState; - -#[derive(Parser, Debug)] -#[command( - name = "stackarr-bootstrap", - version, - about = "StackArr bootstrap/discovery node" -)] -struct Cli { - /// Path to the configuration file - #[arg(short, long, default_value = "bootstrap.toml", env = "BOOTSTRAP_CONFIG")] - config: PathBuf, - - /// Log level - #[arg(long, env = "BOOTSTRAP_LOG_LEVEL", default_value = "info")] - log_level: String, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - // Init tracing - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new(&cli.log_level)); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(true) - .init(); - - // Load config - let content = std::fs::read_to_string(&cli.config) - .map_err(|e| anyhow::anyhow!("failed to read config {}: {e}", cli.config.display()))?; - let config: Config = toml::from_str(&content) - .map_err(|e| anyhow::anyhow!("failed to parse config: {e}"))?; - - let listen_addr = format!("{}:{}", config.bootstrap.bind_addr, config.bootstrap.port); - - // Initialize SQLite persistence - let db = BootstrapDb::new(&config.bootstrap.database_path) - .map_err(|e| anyhow::anyhow!("failed to open bootstrap database: {e}"))?; - tracing::info!(path = %config.bootstrap.database_path, "bootstrap database initialized"); - - let state = Arc::new(BootstrapState::new(&config.bootstrap, db)); - - // Spawn cleanup task - let cleanup_state = Arc::clone(&state); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(30)); - loop { - interval.tick().await; - cleanup_state.sweep_expired().await; - } - }); - - // Build router - let app = Router::new() - .route("/api/v1/servers/register", post(routes::register_server)) - .route( - "/api/v1/servers/{server_id}", - delete(routes::deregister_server), - ) - .route("/api/v1/claims", post(routes::create_claim)) - .route( - "/api/v1/claims/{code}/redeem", - post(routes::redeem_claim), - ) - .route( - "/api/v1/servers/by-name/{name}", - get(routes::lookup_by_name), - ) - .route( - "/api/v1/servers/register-name", - post(routes::register_name), - ) - .route( - "/api/v1/servers/recover-name", - post(routes::recover_name), - ) - .route( - "/api/v1/servers/check-name/{name}", - get(routes::check_name), - ) - .route( - "/api/v1/servers/check-port", - post(routes::check_port), - ) - .route("/api/v1/health", get(routes::health)) - .route("/health", get(routes::health)) - .layer(CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any)) - .with_state(state); - - tracing::info!(addr = %listen_addr, "starting bootstrap node"); - let listener = tokio::net::TcpListener::bind(&listen_addr).await?; - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await?; - - Ok(()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/routes.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/routes.rs deleted file mode 100644 index 32eb2a53..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/routes.rs +++ /dev/null @@ -1,625 +0,0 @@ -use std::net::IpAddr; -use std::sync::Arc; -use std::time::Instant; - -use axum::extract::{ConnectInfo, Path, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::IntoResponse; -use axum::Json; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::db::UpsertResult; -use crate::state::{BootstrapState, ServerRegistration}; - -// ── Auth helper ───────────────────────────────────────────────────────────── - -fn validate_token(headers: &HeaderMap, expected: &str) -> bool { - headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")) - .is_some_and(|token| token.trim() == expected) -} - -// ── Server registration ───────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RegisterRequest { - pub server_id: Uuid, - pub server_name: String, - pub local_ips: Vec, - pub port: u16, - pub version: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RegisterResponse { - public_ip: IpAddr, - ttl_secs: u64, -} - -/// Extract the real client IP, checking CF and proxy headers before falling back to ConnectInfo. -fn real_ip(headers: &HeaderMap, fallback: IpAddr) -> IpAddr { - // Cloudflare sets this to the true client IP - if let Some(val) = headers.get("cf-connecting-ip") { - if let Ok(s) = val.to_str() { - if let Ok(ip) = s.trim().parse::() { - return ip; - } - } - } - // Standard reverse proxy header - if let Some(val) = headers.get("x-forwarded-for") { - if let Ok(s) = val.to_str() { - if let Some(first) = s.split(',').next() { - if let Ok(ip) = first.trim().parse::() { - return ip; - } - } - } - } - fallback -} - -pub async fn register_server( - State(state): State>, - ConnectInfo(addr): ConnectInfo, - headers: HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - if !validate_token(&headers, &state.bootstrap_token) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid token"}))).into_response(); - } - - let public_ip = real_ip(&headers, addr.ip()); - let ttl_secs = state.registration_ttl.as_secs(); - - // Persist server name to SQLite - match state - .db - .upsert_server_name(&body.server_name, &body.server_id.to_string()) - .await - { - Ok(UpsertResult::Conflict(existing_id)) => { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": "server name already claimed by another server", - "existing_server_id": existing_id - })), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to persist server name to SQLite"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "database error"})), - ) - .into_response(); - } - Ok(UpsertResult::Created) | Ok(UpsertResult::Updated) => {} - } - - state.servers.insert( - body.server_id, - ServerRegistration { - server_id: body.server_id, - server_name: body.server_name, - public_ip, - local_ips: body.local_ips, - port: body.port, - version: body.version, - last_heartbeat: Instant::now(), - }, - ); - - tracing::debug!(server_id = %body.server_id, %public_ip, "server registered/heartbeat"); - - Json(RegisterResponse { public_ip, ttl_secs }).into_response() -} - -pub async fn deregister_server( - State(state): State>, - headers: HeaderMap, - Path(server_id): Path, -) -> impl IntoResponse { - if !validate_token(&headers, &state.bootstrap_token) { - return StatusCode::UNAUTHORIZED.into_response(); - } - - state.servers.remove(&server_id); - tracing::info!(%server_id, "server deregistered"); - StatusCode::NO_CONTENT.into_response() -} - -// ── Claim codes ───────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateClaimRequest { - pub server_id: Uuid, - pub code: Option, // if provided, use this code; if None, generate 4-char (legacy) - pub claim_type: Option, // "invite" or "device", defaults to "device" - pub invite_code: Option, // pass-through for invite-type claims - pub ttl_secs: Option, // override TTL (invites may live longer) - // Keep client_token as optional for backward compat with legacy flow - pub client_token: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct CreateClaimResponse { - code: String, - expires_in_secs: u64, -} - -pub async fn create_claim( - State(state): State>, - headers: HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - if !validate_token(&headers, &state.bootstrap_token) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid token"}))).into_response(); - } - - // Verify server is registered - if !state.servers.contains_key(&body.server_id) { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "server not registered"})), - ) - .into_response(); - } - - // Use server-provided code or generate a 4-char code (legacy) - let code = match body.code { - Some(ref c) => c.to_uppercase(), - None => state.generate_claim_code(), - }; - - let ttl_secs = body.ttl_secs.unwrap_or(state.claim_ttl.as_secs()); - let claim_type = body.claim_type.unwrap_or_else(|| "device".to_string()); - - state.claims.insert( - code.clone(), - crate::state::PendingClaim { - server_id: body.server_id, - client_token: body.client_token, - expires_at: Instant::now() + std::time::Duration::from_secs(ttl_secs), - claim_type: claim_type.clone(), - invite_code: body.invite_code.clone(), - }, - ); - - tracing::info!(server_id = %body.server_id, %code, %claim_type, "claim code created"); - - Json(CreateClaimResponse { - code, - expires_in_secs: ttl_secs, - }) - .into_response() -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RedeemClaimResponse { - server_id: Uuid, - server_name: String, - public_ip: IpAddr, - local_ips: Vec, - port: u16, - version: String, - claim_type: String, // "invite" or "device" - #[serde(skip_serializing_if = "Option::is_none")] - invite_code: Option, // present if claim_type == "invite" - #[serde(skip_serializing_if = "Option::is_none")] - client_token: Option, // present for legacy device claims -} - -pub async fn redeem_claim( - State(state): State>, - Path(code): Path, -) -> impl IntoResponse { - let code = code.to_uppercase(); - - // Remove and consume the claim (one-time use) - let Some((_, claim)) = state.claims.remove(&code) else { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "invalid or expired claim code"})), - ) - .into_response(); - }; - - // Check expiry - if Instant::now() >= claim.expires_at { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "claim code has expired"})), - ) - .into_response(); - } - - // Look up the server - let Some(server) = state.servers.get(&claim.server_id) else { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "server is no longer registered"})), - ) - .into_response(); - }; - - tracing::info!(server_id = %claim.server_id, %code, claim_type = %claim.claim_type, "claim redeemed"); - - Json(RedeemClaimResponse { - server_id: server.server_id, - server_name: server.server_name.clone(), - public_ip: server.public_ip, - local_ips: server.local_ips.clone(), - port: server.port, - version: server.version.clone(), - claim_type: claim.claim_type, - invite_code: claim.invite_code, - client_token: claim.client_token, - }) - .into_response() -} - -// ── Server name lookup ─────────────────────────────────────────────────────── - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LookupByNameResponse { - server_id: Uuid, - server_name: String, - public_ip: IpAddr, - local_ips: Vec, - port: u16, -} - -pub async fn lookup_by_name( - State(state): State>, - Path(name): Path, -) -> impl IntoResponse { - let name_lower = name.to_lowercase(); - - // Search the live servers DashMap for a matching server name - let found = state.servers.iter().find_map(|entry| { - if entry.server_name.to_lowercase() == name_lower { - Some(LookupByNameResponse { - server_id: entry.server_id, - server_name: entry.server_name.clone(), - public_ip: entry.public_ip, - local_ips: entry.local_ips.clone(), - port: entry.port, - }) - } else { - None - } - }); - - match found { - Some(resp) => { - tracing::debug!(name = %name, server_id = %resp.server_id, "server name lookup hit"); - Json(resp).into_response() - } - None => { - ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "server not found"})), - ) - .into_response() - } - } -} - -// ── Server name registration (BIP39 recovery) ─────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RegisterNameRequest { - pub server_id: Uuid, - pub server_name: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RegisterNameResponse { - server_name: String, - recovery_phrase: String, -} - -/// Generate a 12-word BIP39 mnemonic and return the phrase + its SHA-256 hash. -fn generate_recovery_phrase() -> Result<(String, String), String> { - use bip39::Mnemonic; - use rand::RngCore; - use sha2::{Digest, Sha256}; - - let mut entropy = [0u8; 16]; - rand::rng().fill_bytes(&mut entropy); - let mnemonic = Mnemonic::from_entropy(&entropy) - .map_err(|e| format!("failed to generate mnemonic: {e}"))?; - let phrase = mnemonic.to_string(); - let hash = Sha256::digest(phrase.as_bytes()); - let hex_hash = format!("{hash:x}"); - Ok((phrase, hex_hash)) -} - -/// Hash a recovery phrase with SHA-256 and return the hex string. -fn hash_phrase(phrase: &str) -> String { - use sha2::{Digest, Sha256}; - let hash = Sha256::digest(phrase.as_bytes()); - format!("{hash:x}") -} - -pub async fn register_name( - State(state): State>, - headers: HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - if !validate_token(&headers, &state.bootstrap_token) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid token"}))).into_response(); - } - - let server_name = body.server_name.trim().to_string(); - if server_name.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "server_name is required"}))).into_response(); - } - - // Check if name already exists - let existing_owner = match state.db.lookup_by_name(&server_name).await { - Ok(owner) => owner, - Err(e) => { - tracing::error!(error = %e, "failed to query server name"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "database error"}))).into_response(); - } - }; - - let server_id_str = body.server_id.to_string(); - - match existing_owner { - Some(ref owner) if owner != &server_id_str => { - return ( - StatusCode::CONFLICT, - Json(serde_json::json!({"error": "server name already claimed by another server"})), - ) - .into_response(); - } - None => { - if let Err(e) = state.db.upsert_server_name(&server_name, &server_id_str).await { - tracing::error!(error = %e, "failed to upsert server name"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "database error"}))).into_response(); - } - } - Some(_) => { - // Owned by this server — regenerate the recovery phrase - } - } - - let (phrase, hex_hash) = match generate_recovery_phrase() { - Ok(v) => v, - Err(e) => { - tracing::error!(error = %e, "failed to generate recovery phrase"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "failed to generate recovery phrase"}))).into_response(); - } - }; - - if let Err(e) = state.db.set_recovery_key_hash(&server_name, &hex_hash).await { - tracing::error!(error = %e, "failed to store recovery key hash"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "database error"}))).into_response(); - } - - tracing::info!(%server_name, server_id = %body.server_id, "server name registered with recovery phrase"); - - Json(RegisterNameResponse { - server_name, - recovery_phrase: phrase, - }) - .into_response() -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RecoverNameRequest { - pub server_name: String, - pub recovery_phrase: String, - pub new_server_id: Uuid, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RecoverNameResponse { - server_name: String, - recovery_phrase: String, -} - -pub async fn recover_name( - State(state): State>, - headers: HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - if !validate_token(&headers, &state.bootstrap_token) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid token"}))).into_response(); - } - - let server_name = body.server_name.trim().to_string(); - - let stored_hash = match state.db.get_recovery_key_hash(&server_name).await { - Ok(Some(hash)) => hash, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "server name not found or no recovery key set"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to query recovery key hash"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "database error"}))).into_response(); - } - }; - - let provided_hash = hash_phrase(body.recovery_phrase.trim()); - if provided_hash != stored_hash { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({"error": "invalid recovery phrase"})), - ) - .into_response(); - } - - let new_server_id_str = body.new_server_id.to_string(); - if let Err(e) = state.db.transfer_server_name(&server_name, &new_server_id_str).await { - tracing::error!(error = %e, "failed to transfer server name"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "database error"}))).into_response(); - } - - // Rotate recovery phrase after successful recovery - let (new_phrase, new_hex_hash) = match generate_recovery_phrase() { - Ok(v) => v, - Err(e) => { - tracing::error!(error = %e, "failed to generate new recovery phrase"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "failed to generate recovery phrase"}))).into_response(); - } - }; - - if let Err(e) = state.db.set_recovery_key_hash(&server_name, &new_hex_hash).await { - tracing::error!(error = %e, "failed to store new recovery key hash"); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "database error"}))).into_response(); - } - - tracing::info!(%server_name, new_server_id = %body.new_server_id, "server name recovered and transferred"); - - Json(RecoverNameResponse { - server_name, - recovery_phrase: new_phrase, - }) - .into_response() -} - -// ── Name availability check ────────────────────────────────────────────────── - -pub async fn check_name( - State(state): State>, - Path(name): Path, -) -> impl IntoResponse { - let name_lower = name.to_lowercase(); - - // Check SQLite for the name - let owner = state.db.lookup_by_name(&name).await.unwrap_or(None); - - match owner { - Some(server_id) => { - Json(serde_json::json!({ - "available": false, - "ownedByServer": server_id, - })) - .into_response() - } - None => { - // Also check live DashMap in case the name is registered but not yet in SQLite - let live_owner = state.servers.iter().find_map(|entry| { - if entry.server_name.to_lowercase() == name_lower { - Some(entry.server_id.to_string()) - } else { - None - } - }); - - match live_owner { - Some(server_id) => { - Json(serde_json::json!({ - "available": false, - "ownedByServer": server_id, - })) - .into_response() - } - None => { - Json(serde_json::json!({ - "available": true, - })) - .into_response() - } - } - } - } -} - -// ── Port forward check ────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CheckPortRequest { - pub server_id: Uuid, -} - -pub async fn check_port( - State(state): State>, - headers: HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - if !validate_token(&headers, &state.bootstrap_token) { - return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid token"}))).into_response(); - } - - let server = match state.servers.get(&body.server_id) { - Some(s) => s, - None => { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({"error": "server not registered"})), - ) - .into_response(); - } - }; - - let public_ip = server.public_ip; - let port = server.port; - drop(server); // Release DashMap ref before making HTTP call - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - - let url = format!("http://{public_ip}:{port}/api/v1/system/status"); - let start = Instant::now(); - - match client.get(&url).send().await { - Ok(resp) => { - let latency_ms = start.elapsed().as_millis() as u64; - let status_code = resp.status().as_u16(); - Json(serde_json::json!({ - "reachable": true, - "publicIp": public_ip.to_string(), - "port": port, - "latencyMs": latency_ms, - "statusCode": status_code, - })) - .into_response() - } - Err(e) => { - Json(serde_json::json!({ - "reachable": false, - "publicIp": public_ip.to_string(), - "port": port, - "error": e.to_string(), - })) - .into_response() - } - } -} - -// ── Health ─────────────────────────────────────────────────────────────────── - -pub async fn health(State(state): State>) -> Json { - Json(serde_json::json!({ - "status": "ok", - "servers": state.servers.len(), - "pending_claims": state.claims.len(), - })) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/state.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/state.rs deleted file mode 100644 index 81560e8d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-bootstrap/src/state.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::net::IpAddr; -use std::time::{Duration, Instant}; - -use dashmap::DashMap; -use rand::Rng; -use uuid::Uuid; - -use crate::config::BootstrapSection; -use crate::db::BootstrapDb; - -pub struct BootstrapState { - pub servers: DashMap, - pub claims: DashMap, - pub bootstrap_token: String, - pub registration_ttl: Duration, - pub claim_ttl: Duration, - pub db: BootstrapDb, -} - -pub struct ServerRegistration { - pub server_id: Uuid, - pub server_name: String, - pub public_ip: IpAddr, - pub local_ips: Vec, - pub port: u16, - pub version: String, - pub last_heartbeat: Instant, -} - -pub struct PendingClaim { - pub server_id: Uuid, - pub client_token: Option, - pub expires_at: Instant, - pub claim_type: String, // "invite" or "device" - pub invite_code: Option, // present if claim_type == "invite" -} - -impl BootstrapState { - pub fn new(config: &BootstrapSection, db: BootstrapDb) -> Self { - Self { - servers: DashMap::new(), - claims: DashMap::new(), - bootstrap_token: config.bootstrap_token.clone(), - registration_ttl: Duration::from_secs(config.registration_ttl_secs), - claim_ttl: Duration::from_secs(config.claim_ttl_secs), - db, - } - } - - /// Generate a unique 4-character alphanumeric claim code (uppercase). - pub fn generate_claim_code(&self) -> String { - const CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1 to avoid confusion - let mut rng = rand::rng(); - loop { - let code: String = (0..4) - .map(|_| { - let idx = rng.random_range(0..CHARS.len()); - CHARS[idx] as char - }) - .collect(); - // Ensure no collision with active claims - if !self.claims.contains_key(&code) { - return code; - } - } - } - - /// Remove expired server registrations and claim codes. - pub async fn sweep_expired(&self) { - let now = Instant::now(); - - self.servers.retain(|_, reg| { - now.duration_since(reg.last_heartbeat) < self.registration_ttl - }); - - self.claims.retain(|_, claim| now < claim.expires_at); - - if let Err(e) = self.db.sweep_expired_claims().await { - tracing::warn!(error = %e, "failed to sweep expired claims from SQLite"); - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/Cargo.toml deleted file mode 100644 index 1677ff53..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "stackarr-cardigann-parity" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-cardigann = { workspace = true } -stackarr-indexer = { workspace = true } - -reqwest = { workspace = true } -tokio = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } - -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -chrono = { workspace = true } -anyhow = { workspace = true } -regex = { workspace = true } -urlencoding = { workspace = true } -clap = { version = "4", features = ["derive"] } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/docker/docker-compose.parity.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/docker/docker-compose.parity.yml deleted file mode 100644 index 0e18dc89..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/docker/docker-compose.parity.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - prowlarr-parity: - image: lscr.io/linuxserver/prowlarr:latest - container_name: prowlarr-parity - environment: - - PUID=1000 - - PGID=1000 - - TZ=Australia/Sydney - volumes: - - prowlarr-parity-config:/config - ports: - - "9797:9696" - restart: "no" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:9696/ping"] - interval: 5s - timeout: 3s - retries: 30 - - flaresolverr: - image: ghcr.io/flaresolverr/flaresolverr:latest - container_name: flaresolverr-parity - environment: - - LOG_LEVEL=info - ports: - - "8192:8191" - restart: "no" - -volumes: - prowlarr-parity-config: diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/comparator.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/comparator.rs deleted file mode 100644 index cfc2bbdd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/comparator.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Compare Prowlarr search results with StackArr Cardigann engine results. - -use std::collections::HashMap; -use std::path::Path; - -use anyhow::{Context, Result}; -use stackarr_cardigann::search::{CardigannIndexer, CardigannRelease, SearchQuery, SearchType}; -use stackarr_cardigann::CardigannEngine; - -use crate::prowlarr_api::{ProwlarrClient, ProwlarrRelease}; - -/// Result of a parity test for one indexer + one query. -#[derive(Debug, Clone)] -pub struct ParityResult { - pub indexer_name: String, - pub indexer_id: String, - pub query: String, - pub prowlarr_count: usize, - pub stackarr_count: usize, - pub matched_titles: usize, - pub prowlarr_only: Vec, - pub stackarr_only: Vec, - pub size_mismatches: Vec, - pub parity_pct: f64, - pub prowlarr_error: Option, - pub stackarr_error: Option, -} - -#[derive(Debug, Clone)] -pub struct SizeMismatch { - pub title: String, - pub prowlarr_size: i64, - pub stackarr_size: i64, -} - -/// Run parity tests across all configured indexers. -pub async fn run_parity_tests( - prowlarr: &ProwlarrClient, - engine: &CardigannEngine, - queries: &[String], - concurrency: usize, -) -> Result> { - let indexers = prowlarr.list_indexers().await?; - tracing::info!(count = indexers.len(), "found Prowlarr indexers"); - - let mut results = Vec::new(); - - // Process indexers with limited concurrency - let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(concurrency)); - - for indexer in &indexers { - if !indexer.enable { - continue; - } - - // Find the matching Cardigann definition - let definition_file = indexer - .fields - .iter() - .find(|f| f.name == "definitionFile") - .and_then(|f| f.value.as_ref()) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let def = engine.get_definition(definition_file); - if def.is_none() { - tracing::debug!( - name = %indexer.name, - definition = definition_file, - "no matching Cardigann definition, skipping" - ); - continue; - } - - for query in queries { - let permit = semaphore.clone().acquire_owned().await?; - - let prowlarr = prowlarr.clone(); - let indexer_id = indexer.id; - let indexer_name = indexer.name.clone(); - let def_id = definition_file.to_owned(); - let query_str = query.clone(); - let engine_def = def.unwrap().clone(); - - // Search Prowlarr - let prowlarr_result = prowlarr.search_indexer(indexer_id, &query_str).await; - - // Search StackArr Cardigann engine - let stackarr_result = { - let config = extract_config_from_prowlarr(&indexer.fields); - match CardigannIndexer::new(engine_def, config, indexer_id) { - Ok(cardigann) => { - let sq = SearchQuery { - query: query_str.clone(), - ..Default::default() - }; - cardigann.search(&sq).await - } - Err(e) => Err(e), - } - }; - - let result = compare_results( - &indexer_name, - &def_id, - &query_str, - prowlarr_result, - stackarr_result, - ); - - results.push(result); - drop(permit); - } - } - - Ok(results) -} - -/// Compare two sets of results. -fn compare_results( - indexer_name: &str, - indexer_id: &str, - query: &str, - prowlarr: Result>, - stackarr: Result>, -) -> ParityResult { - let (prowlarr_releases, prowlarr_error) = match prowlarr { - Ok(r) => (r, None), - Err(e) => (Vec::new(), Some(e.to_string())), - }; - - let (stackarr_releases, stackarr_error) = match stackarr { - Ok(r) => (r, None), - Err(e) => (Vec::new(), Some(e.to_string())), - }; - - // Normalize titles for comparison - let prowlarr_titles: HashMap = prowlarr_releases - .iter() - .filter_map(|r| r.title.as_ref().map(|t| (normalize_title(t), r))) - .collect(); - - let stackarr_titles: HashMap = - stackarr_releases - .iter() - .map(|r| (normalize_title(&r.title), r)) - .collect(); - - // Find matches and mismatches - let mut matched = 0; - let mut size_mismatches = Vec::new(); - let mut prowlarr_only = Vec::new(); - let mut stackarr_only = Vec::new(); - - for (norm_title, pr) in &prowlarr_titles { - if let Some(sr) = stackarr_titles.get(norm_title) { - matched += 1; - // Check size parity (within 5% tolerance) - let ps = pr.size.unwrap_or(0); - let ss = sr.size; - if ps > 0 && ss > 0 { - let diff = (ps - ss).unsigned_abs() as f64; - let max = ps.max(ss) as f64; - if diff / max > 0.05 { - size_mismatches.push(SizeMismatch { - title: pr.title.clone().unwrap_or_default(), - prowlarr_size: ps, - stackarr_size: ss, - }); - } - } - } else { - prowlarr_only.push(pr.title.clone().unwrap_or_default()); - } - } - - for (norm_title, sr) in &stackarr_titles { - if !prowlarr_titles.contains_key(norm_title) { - stackarr_only.push(sr.title.clone()); - } - } - - let total = prowlarr_releases.len().max(stackarr_releases.len()); - let parity_pct = if total == 0 { - 100.0 - } else { - (matched as f64 / total as f64) * 100.0 - }; - - ParityResult { - indexer_name: indexer_name.to_owned(), - indexer_id: indexer_id.to_owned(), - query: query.to_owned(), - prowlarr_count: prowlarr_releases.len(), - stackarr_count: stackarr_releases.len(), - matched_titles: matched, - prowlarr_only, - stackarr_only, - size_mismatches, - parity_pct, - prowlarr_error, - stackarr_error, - } -} - -/// Record raw Prowlarr responses for offline replay. -pub async fn record_responses( - prowlarr: &ProwlarrClient, - query: &str, - output_dir: &Path, -) -> Result { - std::fs::create_dir_all(output_dir)?; - - let indexers = prowlarr.list_indexers().await?; - let mut count = 0; - - for indexer in &indexers { - if !indexer.enable { - continue; - } - - match prowlarr.search_indexer(indexer.id, query).await { - Ok(results) => { - let fixture = serde_json::json!({ - "indexer_id": indexer.id, - "indexer_name": indexer.name, - "implementation": indexer.implementation, - "query": query, - "results": results, - }); - - let filename = format!("{}_{}.json", indexer.id, sanitize_filename(&indexer.name)); - let path = output_dir.join(filename); - std::fs::write(&path, serde_json::to_string_pretty(&fixture)?)?; - count += 1; - tracing::info!(name = %indexer.name, results = results.len(), "recorded"); - } - Err(e) => { - tracing::warn!(name = %indexer.name, error = %e, "failed to search"); - } - } - - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - - Ok(count) -} - -/// Normalize a title for fuzzy comparison. -fn normalize_title(title: &str) -> String { - title - .to_lowercase() - .replace(['.', '-', '_'], " ") - .split_whitespace() - .collect::>() - .join(" ") -} - -/// Extract config map from Prowlarr indexer fields. -fn extract_config_from_prowlarr( - fields: &[crate::prowlarr_api::ProwlarrField], -) -> HashMap { - let mut config = HashMap::new(); - for field in fields { - if let Some(ref value) = field.value { - let val = match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - _ => continue, - }; - config.insert(field.name.clone(), val); - } - } - config -} - -fn sanitize_filename(s: &str) -> String { - s.chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) - .collect() -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/main.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/main.rs deleted file mode 100644 index da753988..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/main.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Cardigann parity test harness. -//! -//! Spins up a dedicated Prowlarr instance, provisions all public indexers, -//! sends identical queries to both Prowlarr and StackArr's Cardigann engine, -//! and compares results for parity. - -mod comparator; -mod prowlarr_api; -mod provisioner; -mod report; - -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use stackarr_cardigann::CardigannEngine; - -#[derive(Parser)] -#[command(name = "cardigann-parity", about = "Cardigann engine parity test harness")] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Fetch all Cardigann YAML definitions from Prowlarr's server - FetchDefinitions { - /// Directory to store YAML files - #[arg(short, long, default_value = "definitions")] - output: PathBuf, - /// Definition version - #[arg(short, long, default_value = "11")] - version: u32, - }, - - /// Validate that all definitions can be parsed - ValidateDefinitions { - /// Directory containing YAML files - #[arg(short, long, default_value = "definitions")] - dir: PathBuf, - }, - - /// Provision all public indexers in the Prowlarr parity instance - Provision { - /// Prowlarr API URL - #[arg(long, default_value = "http://localhost:9797")] - prowlarr_url: String, - /// Prowlarr API key (auto-detected from Docker if not specified) - #[arg(long)] - api_key: Option, - /// Only provision public indexers - #[arg(long, default_value = "true")] - public_only: bool, - }, - - /// Run parity tests between Prowlarr and StackArr's Cardigann engine - Test { - /// Prowlarr API URL - #[arg(long, default_value = "http://localhost:9797")] - prowlarr_url: String, - /// Prowlarr API key - #[arg(long)] - api_key: Option, - /// Definitions directory - #[arg(short, long, default_value = "definitions")] - definitions_dir: PathBuf, - /// Search queries to test - #[arg(short, long, default_values_t = vec!["linux".to_string(), "ubuntu".to_string()])] - queries: Vec, - /// Max concurrent indexer tests - #[arg(long, default_value = "3")] - concurrency: usize, - /// Output report path - #[arg(short, long, default_value = "parity-report.md")] - output: PathBuf, - }, - - /// Record Prowlarr responses for offline replay testing - Record { - /// Prowlarr API URL - #[arg(long, default_value = "http://localhost:9797")] - prowlarr_url: String, - /// Prowlarr API key - #[arg(long)] - api_key: Option, - /// Directory to store recorded fixtures - #[arg(short, long, default_value = "fixtures/recorded")] - output: PathBuf, - /// Search query - #[arg(short, long, default_value = "linux")] - query: String, - }, -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), - ) - .init(); - - let cli = Cli::parse(); - - match cli.command { - Command::FetchDefinitions { output, version } => { - let count = - CardigannEngine::fetch_definitions(&output, version).await?; - println!("Fetched {count} definitions to {}", output.display()); - } - - Command::ValidateDefinitions { dir } => { - let mut engine = CardigannEngine::new(&dir); - let count = engine.load_definitions()?; - println!("Successfully parsed {count} definitions from {}", dir.display()); - - // Report any definitions that failed - let total_files = std::fs::read_dir(&dir)? - .filter(|e| { - e.as_ref() - .ok() - .is_some_and(|e| { - e.path().extension().is_some_and(|ext| ext == "yml" || ext == "yaml") - }) - }) - .count(); - - let failed = total_files - count; - if failed > 0 { - println!("WARNING: {failed} definitions failed to parse"); - } else { - println!("All definitions parsed successfully!"); - } - } - - Command::Provision { - prowlarr_url, - api_key, - public_only, - } => { - let api_key = match api_key { - Some(k) => k, - None => prowlarr_api::detect_api_key("prowlarr-parity").await?, - }; - let client = prowlarr_api::ProwlarrClient::new(&prowlarr_url, &api_key); - - // Wait for Prowlarr to be ready - client.wait_ready(Duration::from_secs(60)).await?; - - let count = provisioner::provision_public_indexers(&client).await?; - println!("Provisioned {count} indexers in Prowlarr"); - } - - Command::Test { - prowlarr_url, - api_key, - definitions_dir, - queries, - concurrency, - output, - } => { - let api_key = match api_key { - Some(k) => k, - None => prowlarr_api::detect_api_key("prowlarr-parity").await?, - }; - let client = prowlarr_api::ProwlarrClient::new(&prowlarr_url, &api_key); - client.wait_ready(Duration::from_secs(30)).await?; - - let mut engine = CardigannEngine::new(&definitions_dir); - engine.load_definitions()?; - - let results = - comparator::run_parity_tests(&client, &engine, &queries, concurrency).await?; - - report::write_report(&results, &output)?; - println!("Parity report written to {}", output.display()); - - // Print summary - report::print_summary(&results); - } - - Command::Record { - prowlarr_url, - api_key, - output, - query, - } => { - let api_key = match api_key { - Some(k) => k, - None => prowlarr_api::detect_api_key("prowlarr-parity").await?, - }; - let client = prowlarr_api::ProwlarrClient::new(&prowlarr_url, &api_key); - client.wait_ready(Duration::from_secs(30)).await?; - - let count = comparator::record_responses(&client, &query, &output).await?; - println!("Recorded {count} indexer responses to {}", output.display()); - } - } - - Ok(()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/provisioner.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/provisioner.rs deleted file mode 100644 index d17df2c1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/provisioner.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Auto-provision all public Cardigann indexers in a Prowlarr instance. - -use anyhow::{Context, Result}; -use serde::Deserialize; - -use crate::prowlarr_api::{CreateIndexerPayload, ProwlarrClient, ProwlarrField}; - -/// Metadata from the Prowlarr definition index. -#[derive(Debug, Deserialize)] -struct DefinitionMeta { - id: String, - file: String, - name: String, - #[serde(rename = "type")] - privacy: Option, - implementation: Option, - links: Option>, -} - -/// Provision all public indexers from Prowlarr's definition server. -pub async fn provision_public_indexers(client: &ProwlarrClient) -> Result { - // Fetch the definition index - let defs = fetch_definition_index().await?; - tracing::info!(total = defs.len(), "fetched definition index"); - - // Filter to public only - let public: Vec<_> = defs - .iter() - .filter(|d| d.privacy.as_deref() == Some("public")) - .collect(); - - tracing::info!(count = public.len(), "public indexers to provision"); - - // Get existing indexers to avoid duplicates - let existing = client.list_indexers().await?; - let existing_names: Vec = existing.iter().map(|i| i.name.clone()).collect(); - - let mut added = 0; - - for def in &public { - if existing_names.contains(&def.name) { - tracing::debug!(name = %def.name, "indexer already exists, skipping"); - continue; - } - - let base_url = def - .links - .as_ref() - .and_then(|l| l.first()) - .cloned() - .unwrap_or_default(); - - if base_url.is_empty() { - tracing::warn!(name = %def.name, "no base URL, skipping"); - continue; - } - - let implementation = def - .implementation - .as_deref() - .unwrap_or("cardigann"); - - // Determine config contract and fields based on implementation - let (config_contract, fields) = match implementation { - "cardigann" => ( - "CardigannSettings", - vec![ - ProwlarrField { - name: "definitionFile".into(), - value: Some(serde_json::Value::String(def.file.clone())), - }, - ProwlarrField { - name: "baseUrl".into(), - value: Some(serde_json::Value::String(base_url)), - }, - ], - ), - other => { - // Native implementations use their own config contract - let contract = format!("{other}Settings"); - let fields = vec![ProwlarrField { - name: "baseUrl".into(), - value: Some(serde_json::Value::String(base_url)), - }]; - (contract.as_str().to_owned().leak() as &str, fields) - } - }; - - let payload = CreateIndexerPayload { - name: def.name.clone(), - implementation: capitalize_first(implementation), - implementation_name: capitalize_first(implementation), - config_contract: config_contract.to_owned(), - fields, - enable: true, - app_profile_id: Some(1), - }; - - match client.add_indexer(&payload).await { - Ok(indexer) => { - tracing::info!( - name = %def.name, - id = indexer.id, - "provisioned indexer" - ); - added += 1; - } - Err(e) => { - tracing::warn!(name = %def.name, error = %e, "failed to provision indexer"); - } - } - - // Small delay to avoid overwhelming Prowlarr - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - - Ok(added) -} - -/// Fetch the definition index from Prowlarr's server. -async fn fetch_definition_index() -> Result> { - let url = "https://indexers.prowlarr.com/master/11"; - let client = reqwest::Client::new(); - let defs: Vec = client - .get(url) - .send() - .await? - .json() - .await - .context("failed to parse definition index")?; - Ok(defs) -} - -fn capitalize_first(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().collect::() + chars.as_str(), - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/prowlarr_api.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/prowlarr_api.rs deleted file mode 100644 index 7e8f812c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/prowlarr_api.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Prowlarr REST API client for the parity test harness. - -use std::time::Duration; - -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; - -/// Client for interacting with Prowlarr's v1 REST API. -#[derive(Debug, Clone)] -pub struct ProwlarrClient { - base_url: String, - api_key: String, - http: reqwest::Client, -} - -/// An indexer as returned by Prowlarr's API. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProwlarrIndexer { - pub id: i64, - pub name: String, - pub implementation: String, - pub implementation_name: Option, - pub enable: bool, - pub protocol: String, - pub fields: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProwlarrField { - pub name: String, - #[serde(default)] - pub value: Option, -} - -/// A search result from Prowlarr. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProwlarrRelease { - pub guid: Option, - pub title: Option, - pub download_url: Option, - pub info_url: Option, - pub size: Option, - pub publish_date: Option, - pub seeders: Option, - pub leechers: Option, - pub indexer_id: Option, - pub indexer: Option, - pub categories: Option>, - pub info_hash: Option, - pub imdb_id: Option, - pub tmdb_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProwlarrCategory { - pub id: Option, - pub name: Option, -} - -/// Payload for creating an indexer in Prowlarr. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateIndexerPayload { - pub name: String, - pub implementation: String, - pub implementation_name: String, - pub config_contract: String, - pub fields: Vec, - pub enable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub app_profile_id: Option, -} - -impl ProwlarrClient { - pub fn new(base_url: &str, api_key: &str) -> Self { - Self { - base_url: base_url.trim_end_matches('/').to_owned(), - api_key: api_key.to_owned(), - http: reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("failed to build HTTP client"), - } - } - - /// Wait for Prowlarr to become ready. - pub async fn wait_ready(&self, timeout: Duration) -> Result<()> { - let start = std::time::Instant::now(); - loop { - match self.ping().await { - Ok(_) => return Ok(()), - Err(_) if start.elapsed() < timeout => { - tokio::time::sleep(Duration::from_secs(2)).await; - } - Err(e) => bail!("Prowlarr not ready after {timeout:?}: {e}"), - } - } - } - - async fn ping(&self) -> Result<()> { - let url = format!("{}/ping", self.base_url); - self.http.get(&url).send().await?.error_for_status()?; - Ok(()) - } - - /// List all configured indexers. - pub async fn list_indexers(&self) -> Result> { - let url = format!("{}/api/v1/indexer", self.base_url); - let resp = self - .http - .get(&url) - .header("X-Api-Key", &self.api_key) - .send() - .await? - .error_for_status()?; - Ok(resp.json().await?) - } - - /// Add a new indexer. - pub async fn add_indexer(&self, payload: &CreateIndexerPayload) -> Result { - let url = format!("{}/api/v1/indexer", self.base_url); - let resp = self - .http - .post(&url) - .header("X-Api-Key", &self.api_key) - .json(payload) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - bail!("failed to add indexer '{}': {} - {}", payload.name, status, body); - } - - Ok(resp.json().await?) - } - - /// Search across indexers. - pub async fn search( - &self, - query: &str, - indexer_ids: Option<&[i64]>, - ) -> Result> { - let mut url = format!( - "{}/api/v1/search?query={}&type=search&limit=100", - self.base_url, - urlencoding::encode(query) - ); - - if let Some(ids) = indexer_ids { - for id in ids { - url.push_str(&format!("&indexerIds={id}")); - } - } - - let resp = self - .http - .get(&url) - .header("X-Api-Key", &self.api_key) - .timeout(Duration::from_secs(60)) - .send() - .await? - .error_for_status()?; - - Ok(resp.json().await?) - } - - /// Search a specific indexer. - pub async fn search_indexer( - &self, - indexer_id: i64, - query: &str, - ) -> Result> { - self.search(query, Some(&[indexer_id])).await - } -} - -/// Auto-detect the Prowlarr API key from a Docker container. -pub async fn detect_api_key(container_name: &str) -> Result { - let output = tokio::process::Command::new("docker") - .args(["exec", container_name, "cat", "/config/config.xml"]) - .output() - .await - .context("failed to exec docker command")?; - - let config = String::from_utf8_lossy(&output.stdout); - let re = regex::Regex::new(r"([^<]+)")?; - let caps = re - .captures(&config) - .ok_or_else(|| anyhow::anyhow!("could not find ApiKey in Prowlarr config.xml"))?; - - Ok(caps[1].to_owned()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/report.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/report.rs deleted file mode 100644 index 12b608d9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann-parity/src/report.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! Parity report generation. - -use std::path::Path; - -use anyhow::Result; - -use crate::comparator::ParityResult; - -/// Write a markdown parity report. -pub fn write_report(results: &[ParityResult], path: &Path) -> Result<()> { - let mut md = String::new(); - - md.push_str(&format!( - "# Cardigann Parity Report — {}\n\n", - chrono::Utc::now().format("%Y-%m-%d %H:%M UTC") - )); - - // Summary - let total = results.len(); - let perfect = results.iter().filter(|r| r.parity_pct >= 99.0).count(); - let good = results - .iter() - .filter(|r| r.parity_pct >= 80.0 && r.parity_pct < 99.0) - .count(); - let poor = results.iter().filter(|r| r.parity_pct < 80.0).count(); - let errored = results - .iter() - .filter(|r| r.prowlarr_error.is_some() || r.stackarr_error.is_some()) - .count(); - - let avg_parity = if total > 0 { - results.iter().map(|r| r.parity_pct).sum::() / total as f64 - } else { - 0.0 - }; - - md.push_str("## Summary\n\n"); - md.push_str(&format!("- **Tests run**: {total}\n")); - md.push_str(&format!("- **Average parity**: {avg_parity:.1}%\n")); - md.push_str(&format!( - "- **Perfect match** (≥99%): {perfect} ({:.0}%)\n", - if total > 0 { - perfect as f64 / total as f64 * 100.0 - } else { - 0.0 - } - )); - md.push_str(&format!("- **Good** (80-99%): {good}\n")); - md.push_str(&format!("- **Poor** (<80%): {poor}\n")); - md.push_str(&format!("- **Errors**: {errored}\n\n")); - - // Per-indexer table - md.push_str("## Per-Indexer Results\n\n"); - md.push_str("| Indexer | Query | Prowlarr | StackArr | Match | Parity | Issues |\n"); - md.push_str("|---------|-------|----------|----------|-------|--------|--------|\n"); - - for r in results { - let issues = if let Some(ref e) = r.prowlarr_error { - format!("Prowlarr: {}", truncate(e, 40)) - } else if let Some(ref e) = r.stackarr_error { - format!("StackArr: {}", truncate(e, 40)) - } else if !r.size_mismatches.is_empty() { - format!("{} size mismatches", r.size_mismatches.len()) - } else if r.parity_pct >= 99.0 { - "-".to_owned() - } else { - format!( - "+{} / -{}", - r.stackarr_only.len(), - r.prowlarr_only.len() - ) - }; - - md.push_str(&format!( - "| {} | {} | {} | {} | {} | {:.0}% | {} |\n", - r.indexer_name, - r.query, - r.prowlarr_count, - r.stackarr_count, - r.matched_titles, - r.parity_pct, - issues, - )); - } - - // Detailed mismatches for poor results - let poor_results: Vec<_> = results.iter().filter(|r| r.parity_pct < 80.0).collect(); - if !poor_results.is_empty() { - md.push_str("\n## Detailed Mismatches\n\n"); - for r in &poor_results { - md.push_str(&format!("### {} — \"{}\" ({:.0}%)\n\n", r.indexer_name, r.query, r.parity_pct)); - - if !r.prowlarr_only.is_empty() { - md.push_str("**Prowlarr only:**\n"); - for t in r.prowlarr_only.iter().take(10) { - md.push_str(&format!("- {t}\n")); - } - } - if !r.stackarr_only.is_empty() { - md.push_str("\n**StackArr only:**\n"); - for t in r.stackarr_only.iter().take(10) { - md.push_str(&format!("- {t}\n")); - } - } - md.push('\n'); - } - } - - std::fs::write(path, &md)?; - Ok(()) -} - -/// Print a summary to stdout. -pub fn print_summary(results: &[ParityResult]) { - let total = results.len(); - if total == 0 { - println!("No results to summarize."); - return; - } - - let perfect = results.iter().filter(|r| r.parity_pct >= 99.0).count(); - let avg = results.iter().map(|r| r.parity_pct).sum::() / total as f64; - let errored = results - .iter() - .filter(|r| r.prowlarr_error.is_some() || r.stackarr_error.is_some()) - .count(); - - println!("\n--- Parity Summary ---"); - println!("Tests: {total}"); - println!("Average: {avg:.1}%"); - println!("Perfect: {perfect}/{total}"); - println!("Errors: {errored}/{total}"); - - // Show worst performers - let mut sorted: Vec<_> = results.iter().collect(); - sorted.sort_by(|a, b| a.parity_pct.partial_cmp(&b.parity_pct).unwrap()); - - println!("\nWorst 5:"); - for r in sorted.iter().take(5) { - println!( - " {:<30} \"{:<10}\" {:.0}% (P:{} S:{})", - r.indexer_name, r.query, r.parity_pct, r.prowlarr_count, r.stackarr_count - ); - } -} - -fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_owned() - } else { - format!("{}...", &s[..max]) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/Cargo.toml deleted file mode 100644 index 593cafe1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "stackarr-cardigann" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml = "0.9" -scraper = "0.22" -indexmap = { workspace = true, features = ["serde"] } - -reqwest = { workspace = true } -tokio = { workspace = true } -chrono = { workspace = true } -regex = { workspace = true } -url = { workspace = true } -urlencoding = { workspace = true } - -tracing = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["test-util"] } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/0daykiev.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/0daykiev.yml deleted file mode 100644 index 8cc88fa2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/0daykiev.yml +++ /dev/null @@ -1,218 +0,0 @@ ---- -id: 0daykiev -name: 0day.kiev -description: "0day.kiev.ua is a UKRAINIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: uk-UA -type: private -encoding: windows-1251 -links: - - https://tracker.0day.community/ -legacylinks: - - https://tracker.0day.kiev.ua/ - -caps: - categorymappings: - - {id: 10, cat: Movies, desc: "Фильмы (Movies)"} - - {id: 16, cat: Movies/HD, desc: "HD / Фильмы (HD / Movies)"} - - {id: 30, cat: TV/HD, desc: "HD / Сериалы (HD / TV Shows)"} - - {id: 27, cat: Movies/HD, desc: "HD / Мультфильмы (HD / Cartoons)"} - - {id: 17, cat: PC/Games, desc: "Игры / ПК (Games / PC)"} - - {id: 14, cat: Audio, desc: "Музыка / Аудио (Music / Audio)"} - - {id: 29, cat: TV, desc: "Мультсериалы (TV Series)"} - - {id: 11, cat: Movies, desc: "Мультфильмы (Cartoons)"} - - {id: 28, cat: TV/Documentary, desc: "HD / Документальное (HD / Documentary)"} - - {id: 18, cat: PC/0day, desc: "Софт / Windows (Software / Windows)"} - - {id: 19, cat: TV, desc: "TV / Сериалы (TV shows)"} - - {id: 31, cat: Other, desc: "Прочее (Other)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: true - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "The tracker has a system for deleting inactive accounts after 6 months from your last visit to the tracker." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: "/browse.php" - error: - - selector: div.maintitle:contains("Ошибка") - message: - selector: div.borderwrap table.embedded - test: - path: browse.php - selector: a[href="/logout.php"] - -search: - paths: - # https://tracker.0day.kiev.ua/browse.php?c10=1&c27=1&search=endgame&incldead=1&where=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 gold, 4 noseed, 5 silver - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 title, 1 title and descr - where: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E01 to сері 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сері $1"] - - name: re_replace # S01E01 to сезон 1 сері 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сері $2"] - - rows: - selector: table > tbody > tr.rowtorrentinfo - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езони?.+?(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езони?.+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ері[їяй]|Епізоди?)\\s+з\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ері[їяй]|Епізоди?)\\s+з\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езони?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езони?", "S$1"] - - name: re_replace - args: ["(?i)(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ері[їяй]|Епізоди?)\\s+з\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ері[їяй]|Епізоди?)", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV\\s?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT\\s?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB\\s?DL\\s?Rip\\b", "WEBDL"] - - name: re_replace - args: ["(?i)\\bWEB Rip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB DL\\b", "WEBDL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date: - selector: td:nth-child(2) i - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-child(4) - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - grabs: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - filters: - - name: split - args: ["|", 0] - leechers: - selector: td:nth-child(6) - filters: - - name: split - args: ["|", 1] - downloadvolumefactor: - case: - img[src="pic/freedownload.gif"]: 0 - img[src="pic/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.2 - description: - selector: a[href^="details.php?id="] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/0magnet.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/0magnet.yml deleted file mode 100644 index 90224362..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/0magnet.yml +++ /dev/null @@ -1,76 +0,0 @@ ---- -id: 0magnet -name: 0Magnet -description: "ØMagnet is a CHINESE Public tracker for Asian 3X (JAV)" -language: en-US -type: public -encoding: UTF-8 -links: - - https://13mag.net/ - - https://16mag.net/ -legacylinks: - - https://0magnet.com/ - - https://9mag.net/ - - https://0magnet.co/ - -caps: - categories: - XXX: XXX - - modes: - search: [q] - -settings: [] - -download: - infohash: - hash: - selector: a[href^="magnet:?xt"] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: a[href^="magnet:?xt"] - attribute: href - filters: - - name: regexp - args: "&dn=(.+?)&" - - name: validfilename - -search: - paths: - - path: search - inputs: - q: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}.{{ end }}" - - rows: - selector: table > tbody > tr:has(td) - filters: - - name: andmatch - - fields: - category: - text: XXX - title: - selector: a - remove: p - details: - selector: a - attribute: href - download: - selector: a - attribute: href - date: - text: now - size: - selector: td.td-size - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/1337x.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/1337x.yml deleted file mode 100644 index 1d643315..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/1337x.yml +++ /dev/null @@ -1,295 +0,0 @@ ---- -id: 1337x -name: 1337x -description: "1337x is a Public torrent site that offers verified torrent downloads" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -# get status and news on domains at the official site https://1337x-status.org/ -links: - - https://1337x.to/ - - https://1337x.st/ - - https://x1337x.ws/ - - https://x1337x.eu/ - - https://x1337x.cc/ -legacylinks: - - https://1337x.is/ - - https://1337x.gd/ - - https://1337x.mrunblock.bond/ - - https://1337x.abcproxy.org/ - - https://1337x.so/ - - https://1337x.unblockit.download/ - - https://1337x.unblockninja.com/ # keyword search not working - - https://1337x.ninjaproxy1.com/ # keyword search not working - - https://1337x.proxyninja.org/ # keyword search not working - - https://1337x.proxyninja.net/ # keyword search not working - - https://1337x.torrentbay.st/ # keyword search not working - - https://1337x.torrentsbay.org/ # keyword search not working - - https://x1337x.se/ - -caps: - categorymappings: - # Anime - - {id: 28, cat: TV/Anime, desc: "Anime/Anime"} - - {id: 78, cat: TV/Anime, desc: "Anime/Dual Audio"} - - {id: 79, cat: TV/Anime, desc: "Anime/Dubbed"} - - {id: 80, cat: TV/Anime, desc: "Anime/Subbed"} - - {id: 81, cat: TV/Anime, desc: "Anime/Raw"} - # Audio - - {id: 22, cat: Audio/MP3, desc: "Music/MP3"} - - {id: 23, cat: Audio/Lossless, desc: "Music/Lossless"} - - {id: 24, cat: Audio, desc: "Music/DVD"} - - {id: 25, cat: Audio/Video, desc: "Music/Video"} - - {id: 26, cat: Audio, desc: "Music/Radio"} - - {id: 27, cat: Audio/Other, desc: "Music/Other"} - - {id: 53, cat: Audio, desc: "Music/Album"} - - {id: 58, cat: Audio, desc: "Music/Box set"} - - {id: 59, cat: Audio, desc: "Music/Discography"} - - {id: 60, cat: Audio, desc: "Music/Single"} - - {id: 68, cat: Audio, desc: "Music/Concerts"} - - {id: 69, cat: Audio, desc: "Music/AAC"} - # Movies - - {id: 1, cat: Movies/DVD, desc: "Movies/DVD"} - - {id: 2, cat: Movies/SD, desc: "Movies/Divx/Xvid"} - - {id: 3, cat: Movies, desc: "Movies/SVCD/VCD"} - - {id: 4, cat: Movies/Foreign, desc: "Movies/Dubs/Dual Audio"} - - {id: 42, cat: Movies/HD, desc: "Movies/HD"} - - {id: 54, cat: Movies/HD, desc: "Movies/h.264/x264"} - - {id: 55, cat: Movies, desc: "Movies/Mp4"} - - {id: 66, cat: Movies/3D, desc: "Movies/3D"} - - {id: 70, cat: Movies/HD, desc: "Movies/HEVC/x265"} - - {id: 73, cat: Movies, desc: "Movies/Bollywood"} - - {id: 76, cat: Movies/UHD, desc: "Movies/UHD"} - # TV - - {id: 5, cat: TV, desc: "TV/DVD"} - - {id: 6, cat: TV, desc: "TV/Divx/Xvid"} - - {id: 7, cat: TV, desc: "TV/SVCD/VCD"} - - {id: 41, cat: TV/HD, desc: "TV/HD"} - - {id: 71, cat: TV, desc: "TV/HEVC/x265"} - - {id: 74, cat: TV, desc: "TV/Cartoons"} - - {id: 75, cat: TV/SD, desc: "TV/SD"} - - {id: 9, cat: TV/Documentary, desc: "TV/Documentary"} - # Apps - - {id: 18, cat: PC, desc: "Apps/PC Software"} - - {id: 19, cat: PC/Mac, desc: "Apps/Mac"} - - {id: 20, cat: PC, desc: "Apps/Linux"} - - {id: 21, cat: PC, desc: "Apps/Other"} - - {id: 56, cat: PC/Mobile-Android, desc: "Apps/Android"} - - {id: 57, cat: PC/Mobile-iOS, desc: "Apps/iOS"} - # Games - - {id: 10, cat: PC/Games, desc: "Games/PC Game"} - - {id: 11, cat: Console/PS3, desc: "Games/PS2"} - - {id: 12, cat: Console/PSP, desc: "Games/PSP"} - - {id: 13, cat: Console/XBox, desc: "Games/Xbox"} - - {id: 14, cat: Console/XBox 360, desc: "Games/Xbox360"} - - {id: 15, cat: Console/PS3, desc: "Games/PS1"} - - {id: 16, cat: Console/Other, desc: "Games/Dreamcast"} - - {id: 17, cat: PC/Mobile-Other, desc: "Games/Other"} - - {id: 43, cat: Console/PS3, desc: "Games/PS3"} - - {id: 44, cat: Console/Wii, desc: "Games/Wii"} - - {id: 45, cat: Console/NDS, desc: "Games/DS"} - - {id: 46, cat: Console/Other, desc: "Games/GameCube"} - - {id: 72, cat: Console/3DS, desc: "Games/3DS"} - - {id: 77, cat: Console/PS4, desc: "Games/PS4"} - - {id: 82, cat: Console/Other, desc: "Games/Switch"} - # XXX - - {id: 48, cat: XXX/DVD, desc: "XXX/Video"} - - {id: 49, cat: XXX/ImageSet, desc: "XXX/Picture"} - - {id: 50, cat: XXX, desc: "XXX/Magazine"} - - {id: 51, cat: XXX, desc: "XXX/Hentai"} - - {id: 67, cat: XXX, desc: "XXX/Games"} - # Other - - {id: 33, cat: Other, desc: "Other/Emulation"} - - {id: 34, cat: Books, desc: "Other/Tutorial"} - - {id: 35, cat: Other, desc: "Other/Sounds"} - - {id: 36, cat: Books/EBook, desc: "Other/E-books"} - - {id: 37, cat: Other, desc: "Other/Images"} - - {id: 38, cat: Other, desc: "Other/Mobile Phone"} - - {id: 39, cat: Books/Comics, desc: "Other/Comics"} - - {id: 40, cat: Other/Misc, desc: "Other/Other"} - - {id: 47, cat: Other, desc: "Other/Nulled Script"} - - {id: 52, cat: Audio/Audiobook, desc: "Other/Audiobook"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: uploader - type: text - label: Filter by Uploader - - name: info_uploader - type: info - label: About filtering by Uploader - default: "You can filter by Uploader by entering a Case Sensitive username, or leave empty to get all results.
Note: this is the username of the Uploader and not the Groupname that often show up at the end of 1337x titles, eg -GalaxyRG." - - name: info_flaresolverr - type: info_flaresolverr - - name: downloadlink - type: select - label: Download link - default: "http://itorrents.org/" - options: - "http://itorrents.org/": iTorrents.org - "magnet:": magnet - - name: downloadlink2 - type: select - label: Download link (fallback) - default: "magnet:" - options: - "http://itorrents.org/": iTorrents.org - "magnet:": magnet - - name: info_download - type: info - label: About the Download links - default: As the iTorrents .torrent download link on this site is known to fail from time to time, we suggest using the magnet link as a fallback. The BTCache and Torrage services are not supported because they require additional user interaction (a captcha for BTCache and a download button on Torrage.) - - name: disablesort - type: checkbox - label: Disable sorting - 1337x prevents sorting searches during high server load, which breaks the indexer when performing a keyword search - disable if you get zero results - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -download: - # the .torrent URL and magnet URI are on the details page - selectors: - - selector: ul li a[href^="{{ .Config.downloadlink }}"] - attribute: href - - selector: ul li a[href^="{{ .Config.downloadlink2 }}"] - attribute: href - -search: - paths: - # present first page of movies tv and music results if there are no search parms supplied (20 hits per page) - - path: "{{ if and (.Keywords) (eq .Config.disablesort .False) }}sort-{{ else }}{{ end }}{{ if .Keywords }}search/{{ .Keywords }}{{ else }}cat/Movies{{ end }}{{ if and (.Keywords) (eq .Config.disablesort .False) }}/{{ .Config.sort }}/{{ .Config.type }}{{ else }}{{ end }}/1/" - - path: "{{ if and (.Keywords) (eq .Config.disablesort .False) }}sort-{{ else }}{{ end }}{{ if .Keywords }}search/{{ .Keywords }}{{ else }}cat/TV{{ end }}{{ if and (.Keywords) (eq .Config.disablesort .False)) }}/{{ .Config.sort }}/{{ .Config.type }}{{ else }}{{ end }}/{{ if .Keywords }}2{{ else }}1{{ end }}/" - - path: "{{ if and (.Keywords) (eq .Config.disablesort .False) }}sort-{{ else }}{{ end }}{{ if .Keywords }}search/{{ .Keywords }}{{ else }}cat/Music{{ end }}{{ if and (.Keywords) (eq .Config.disablesort .False) }}/{{ .Config.sort }}/{{ .Config.type }}{{ else }}{{ end }}/{{ if .Keywords }}3{{ else }}1{{ end }}/" - - path: "{{ if and (.Keywords) (eq .Config.disablesort .False) }}sort-{{ else }}{{ end }}{{ if .Keywords }}search/{{ .Keywords }}{{ else }}cat/Other{{ end }}{{ if and (.Keywords) (eq .Config.disablesort .False) }}/{{ .Config.sort }}/{{ .Config.type }}{{ else }}{{ end }}/{{ if .Keywords }}4{{ else }}1{{ end }}/" - - keywordsfilters: - - name: re_replace # daily standard S2023 > 2023 - args: ["\\bS(20\\d{2})\\b", "$1"] - - rows: - selector: "tr:has(a[href^=\"/torrent/\"]){{ if .Config.uploader }}:has(td[class^=\"coll-5\"]:contains({{ .Config.uploader }})){{ else }}{{ end }}" - - fields: - title_default: - # the movies, tv and music pages abbreviate the title - selector: td[class^="coll-1"] a[href^="/torrent/"] - title_optional: - # the movies, tv and music pages abbreviate the title so we process the href instead. #8137 - optional: true - selector: td[class^="coll-1"] a[href^="/torrent/"]:contains("...") - attribute: href - filters: - - name: urldecode - - name: split - args: ["/", 3] - title: - # title_optional can be empty so use the title_default instead #8586 - text: "{{ if .Result.title_optional }}{{ .Result.title_optional }}{{ else }}{{ .Result.title_default }}{{ end }}" - filters: - - name: re_replace - args: ["-([\\w]+(?:[\\[\\]\\(\\)\\w]+)?)$", "~$1"] - - name: replace - args: ["-", " "] - - name: re_replace - args: ["~([\\w]+(?:[\\[\\]\\(\\)\\w]+)?)$", "-$1"] - - name: replace - args: ["\u000f", ""] # get rid of unwanted character #6582 - # cleanup for Sonarr - - name: re_replace # EP 3 4 to E3-4 - args: ["(?i)\\sEP\\s(\\d{1,2})\\s(E?\\s?\\d{1,2})\\s", " E$1-$2 "] - - name: re_replace # S02E04 05 to S02E04-05 - args: ["(?i)([-_. ])S(\\d{1,2})\\s?E\\s?(\\d{1,2})\\s(E?\\s?\\d{1,2})([-_. ])", "$1S$2E$3-$4$5"] - - name: re_replace - args: ["(?i)AC3\\s?(\\d)\\s(\\d)", "AC3 $1.$2"] - - name: re_replace - args: ["(?i) DD\\s?(\\d)\\s(\\d)", " DD $1.$2"] - - name: re_replace - args: ["(?i) DDP\\s?(\\d)\\s(\\d)", " DDP $1.$2"] - - name: re_replace - args: ["(?i)\\sE\\s?AC3", " EAC3"] - - name: re_replace - args: ["(?i)WEB\\sDL", "WEB-DL"] - - name: re_replace - args: ["(?i)HDTVRIP", "HDTV"] - category_optional: - optional: true - selector: td[class^="coll-1"] a[href^="/sub/"] - attribute: href - filters: - # extract the third part - - name: split - args: ["/", 2] - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}40{{ end }}" - details: - selector: td[class^="coll-1"] a[href^="/torrent/"] - attribute: href - download: - # .torrent link is on the details page - selector: td[class^="coll-1"] a[href^="/torrent/"] - attribute: href - # dates come in three flavours: - date_year: - # (within this year) 7am Sep. 14th - optional: true - selector: td[class^="coll-date"]:not(:contains(":")):not(:contains("'")) - filters: - - name: re_replace - args: ["st|nd|rd|th", ""] - - name: dateparse - args: "htt MMM. d" - date_years: - # (more than a year ago) Apr. 18th '11 - optional: true - selector: td[class^="coll-date"]:contains("'") - filters: - - name: replace - args: ["'", ""] - - name: re_replace - args: ["st|nd|rd|th", ""] - - name: dateparse - args: "MMM. d yy" - date_today: - # (today) 12:25am - optional: true - selector: td[class^="coll-date"]:contains(":") - filters: - - name: fuzzytime - date: - text: "{{ if or .Result.date_year .Result.date_years .Result.date_today }}{{ or .Result.date_year .Result.date_years .Result.date_today }}{{ else }}now{{ end }}" - size: - selector: td[class^="coll-4"] - seeders: - selector: td[class^="coll-2"] - leechers: - selector: td[class^="coll-3"] - _username: - selector: td[class^="coll-5"] - description: - text: "Uploader: {{ .Result._username }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/13city.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/13city.yml deleted file mode 100644 index d04b880e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/13city.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: 13city -name: 13City -description: "13City is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://13city.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影/Movies"} - - {id: 402, cat: TV, desc: "电视剧/TVSeries"} - - {id: 403, cat: TV, desc: "综艺/TV Shows"} - - {id: 405, cat: TV/Anime, desc: "动漫/Animations"} - - {id: 406, cat: Audio/Video, desc: "演唱会、MV/Music Videos"} - - {id: 408, cat: Audio, desc: "音乐/Music"} - - {id: 409, cat: Books/EBook, desc: "电子书/E-book"} - - {id: 409, cat: Audio/Audiobook, desc: "有声读物/A-book"} - - {id: 413, cat: TV/Documentary, desc: "纪录片/Documentary"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. If there is no valid traffic within 7 days of registration (participation in the calculation of sharing rate), the account will be blocked
  2. After the level is reduced to \"Peasant\", if the sharing rate is not improved within 7 days, the account will be blocked
  3. Long-term non-login:
      - UnParked account: 60 consecutive days of non-login → banned
      - Parked account: 180 consecutive days of non-login → banned
      - Nexus Master and above levels are not subject to long-term non-login ban restrictions
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/1ptbar.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/1ptbar.yml deleted file mode 100644 index 032b2f63..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/1ptbar.yml +++ /dev/null @@ -1,232 +0,0 @@ ---- -id: 1ptbar -name: 1ptbar -description: "1ptbar is a CHINESE Private Torrent Tracker for MOVIES / TV / E-LEARNING" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://1ptba.com/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "Movie(電影)", default: true} - - {id: 402, cat: TV, desc: "TV Series(電視影劇)", default: true} - - {id: 403, cat: TV, desc: "TV Shows(電視綜藝)", default: true} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(紀錄教育)", default: true} - - {id: 405, cat: TV/Anime, desc: "Animations(卡通動漫)", default: true} - - {id: 406, cat: Audio/Video, desc: "Music Videos(音樂短片/演唱會)", default: true} - - {id: 407, cat: TV/Sport, desc: "Sports(體育賽事)", default: true} - - {id: 408, cat: Audio, desc: "HQ Audio(高品质音频)", default: true} - - {id: 410, cat: PC/0day, desc: "Software(軟體)", default: true} - - {id: 411, cat: PC/Games, desc: "Games(電子遊戲)", default: true} - - {id: 412, cat: Books/EBook, desc: "eBook(電子書)", default: true} - - {id: 409, cat: Other, desc: "Misc(其他)", default: true} - - {id: 610, cat: XXX/x264, desc: "AV(有碼)/HD Censored", default: false} - - {id: 611, cat: XXX/x264, desc: "AV(無碼)/HD Uncensored", default: false} - - {id: 612, cat: XXX/SD, desc: "AV(有碼)/SD Censored", default: false} - - {id: 613, cat: XXX/SD, desc: "AV(無碼)/SD Uncensored", default: false} - - {id: 614, cat: XXX/DVD, desc: "AV(無碼)/DVDiSo Uncensored", default: false} - - {id: 615, cat: XXX/DVD, desc: "AV(有碼)/DVDiSo Censored", default: false} - - {id: 616, cat: XXX/UHD, desc: "AV(有碼)/Blu-Ray Censored", default: false} - - {id: 617, cat: XXX/UHD, desc: "AV(無碼)/Blu-Ray Uncensored", default: false} - - {id: 618, cat: XXX/Pack, desc: "AV(網站)/0Day", default: false} - - {id: 619, cat: XXX/Pack, desc: "IV(寫真影集)/Video Collection", default: false} - - {id: 620, cat: XXX/ImageSet, desc: "IV(寫真圖集)/Picture Collection", default: false} - - {id: 621, cat: XXX/Other, desc: "H-Game(遊戲)", default: false} - - {id: 622, cat: XXX/Other, desc: "H-Anime(動畫)", default: false} - - {id: 623, cat: XXX/Other, desc: "H-Comic(漫畫)", default: false} - - {id: 624, cat: XXX/Other, desc: "Adult film(成人電影)", default: false} - - {id: 625, cat: XXX/Other, desc: "AV(Gay)/HD", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the 1ptbar Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - - name: info_special - type: info - label: Special Categories - default: "Only Power User or above can view Special section. Searching for XXX categories by users below this class will result in search errors." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 403, 404, 405, 406, 407, 408, 410, 411, 412, 409] - - path: special.php - categories: [610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-last-of-type(6) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-last-of-type(6):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-last-of-type(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-last-of-type(4) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-last-of-type(3) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-last-of-type(2) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.13 2025-12-28 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/3changtrai.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/3changtrai.yml deleted file mode 100644 index 6c6b8496..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/3changtrai.yml +++ /dev/null @@ -1,180 +0,0 @@ ---- -id: 3changtrai -name: 3ChangTrai -description: "3ChangTrai (3CT) is a VIETNAMESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: vi-VN -type: private -encoding: UTF-8 -links: - - https://3changtrai.com/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: TV, desc: "TV Series"} - - {id: 403, cat: TV, desc: "TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries"} - - {id: 405, cat: TV/Anime, desc: "Animations"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - {id: 407, cat: TV/Sport, desc: "Sports"} - - {id: 408, cat: Audio, desc: "HQ Audio"} - - {id: 409, cat: Other, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your account. Inactive accounts will be deleted based on the following provisions:
  1. Nexus Master or later will not be deleted account.
  2. Veteran User or higher will not be deleted if temporarily locked (at User Settings).
  3. Temporarily locked accounts will be deleted if not logged in within 90 days.
  4. Accounts that are not temporarily locked will be deleted if they do not log in within 60 days.
  5. Accounts with no storage for uploads and downloads will be deleted if they do not log in within 30 days or have registered within 14 days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v2.2 2026-02-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/3dtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/3dtorrents.yml deleted file mode 100644 index dd56e769..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/3dtorrents.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: 3dtorrents -name: 3D Torrents -description: "3D Torrents (3DT) is a Private Torrent Tracker for 3D HD / BLURAY MOVIES" -language: en-US -type: private -encoding: UTF-8 -links: - - http://www.3dtorrents.org/ # site does not support https ERR_CONNECTION_CLOSED - -caps: - categorymappings: - - {id: 14, cat: Movies/SD, desc: "Movies XviD"} - - {id: 15, cat: Movies/DVD, desc: "Movies DVD-R"} - - {id: 11, cat: Movies/HD, desc: "Movies 720p"} - - {id: 13, cat: Movies/HD, desc: "Movies 1080p"} - - {id: 16, cat: Movies/3D, desc: "Movies 3DTV"} - - {id: 17, cat: Movies/BluRay, desc: "Movies Blu-ray"} - - {id: 27, cat: Movies/BluRay, desc: "Movies BD25 Encode"} - - {id: 33, cat: Movies/BluRay, desc: "Movies BD9 AVCHD"} - - {id: 22, cat: Movies, desc: "Movies Home Conversions"} - - {id: 32, cat: Movies, desc: "Movies Bluray MKV Remux"} - - {id: 38, cat: Movies, desc: "Movies Studio Conversion"} - - {id: 39, cat: Movies/3D, desc: "Movies VR 360"} - - {id: 23, cat: Movies/3D, desc: "Movies Evo 3D"} - - {id: 36, cat: Movies/UHD, desc: "Movies 4K UHD Bluray"} - - {id: 37, cat: Movies/UHD, desc: "Movies 4K UHD Encode"} - - {id: 21, cat: PC, desc: "3D Software"} - - {id: 28, cat: XXX, desc: "Adult 720p"} - - {id: 29, cat: XXX, desc: "Adult 1080p"} - - {id: 30, cat: XXX, desc: "Adult Blu-ray"} - - {id: 31, cat: Other, desc: "Misc"} - - {id: 19, cat: Audio, desc: "Audio Packs"} - - modes: - search: [q] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 1: desc - 2: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactivity grace period is three months which means that if you do not log in to the 3Dtorrents web site for 90 days, your account will be deleted. If you are VIP your will stay until the VIP period is over." - - name: info_captcha - type: info - label: About Captcha - default: "Note that the captcha on the 3Dtorrents login page is Case Sensitive." - -login: - path: index.php?page=login - method: form - form: form - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - captcha: - type: image - selector: img.captcha - input: private_key - error: - - selector: span.errormsg - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: index.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - page: torrents - category: 0 - # 0 all, 1 Anaglyph 3D, 2 Side by Side,3 Over Under - 3dformat: 0 - # 0 all, 1 active only, 2 dead only - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: "table[cellspacing!=\"1\"].lista > tbody > tr:has(a[href^=\"index.php?page=torrents&category=\"]){{ if .Config.freeleech }}:has(img[src=\"images/sign_free.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - - name: re_replace - args: ["(\\b0\\b)", "31"] - title: - selector: a[href^="index.php?page=torrent-details&id="] - details: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: href - download: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: href - filters: - - name: replace - args: ["index.php?page=torrent-details&id=", "download.php?id="] - poster: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: onmouseover - filters: - - name: regexp - args: ",\\'(.+?)\\'" - genre: - selector: td:nth-child(2) span - description: - text: "{{ .Result.genre }}" - date: - selector: td:nth-last-child(5) - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "dd/MM/yyyy zzz" - size: - selector: td:nth-last-child(4) - seeders: - selector: a[href^="index.php?page=peers"] - leechers: - selector: td:has(a[href^="index.php?page=peers"]) + td - grabs: - selector: a[href^="index.php?page=torrent_history"] - optional: true - default: 0 - downloadvolumefactor: - case: - img[src="images/sign_free.png"]: 0 - img[src="images/50off.png"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# xbtit diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/4thd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/4thd.yml deleted file mode 100644 index aaa1801d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/4thd.yml +++ /dev/null @@ -1,149 +0,0 @@ ---- -id: 4thd -name: 4thD -description: "4th Dimension is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -encoding: UTF-8 -type: private -links: - - https://4thd.xyz/ - -caps: - categorymappings: - - {id: 38, cat: Movies, desc: "Movie Pack"} - - {id: 36, cat: Movies, desc: "Movies/Classic Films"} - - {id: 13, cat: Movies, desc: "Movies/Documentary"} - - {id: 21, cat: Movies/Foreign, desc: "Movies/Foreign"} - - {id: 27, cat: Movies/HD, desc: "MoviesHD"} - - {id: 29, cat: Movies/SD, desc: "Movies/SD"} - - {id: 26, cat: Movies/SD, desc: "Movies/XviD"} - - {id: 45, cat: Movies, desc: "Movies/Kids"} - - {id: 41, cat: TV/Foreign, desc: "Foreign tv"} - - {id: 40, cat: TV, desc: "Mini-Series"} - - {id: 43, cat: TV/HD, desc: "TV H265"} - - {id: 42, cat: TV, desc: "TV Movie"} - - {id: 4, cat: TV/HD, desc: "TV/HD-x720"} - - {id: 14, cat: TV, desc: "TV/Packs"} - - {id: 2, cat: TV/SD, desc: "TV/SD MP4/mkv"} - - {id: 31, cat: TV/SD, desc: "TV/XviD"} - - {id: 18, cat: PC/Games, desc: "Games/PC"} - - {id: 44, cat: Audio, desc: "Music/Pack"} - - {id: 12, cat: Audio/Lossless, desc: "Music/FLAC"} - - {id: 15, cat: Audio/MP3, desc: "Music/MP3"} - - {id: 37, cat: Audio, desc: "Radio"} - - {id: 9, cat: PC/0day, desc: "Apps/PC"} - - {id: 34, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 25, cat: Books/EBook, desc: "eBooks"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form#loginform - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: form#loginform .warning - test: - path: index.php - selector: a[href^="logout.php?auth="] - -search: - paths: - # https://4thd.xyz/torrents.php?order_by=time&order_way=desc&filter_freeleech=1&title=twilight+zone&search_type=1&taglist=&tags_type=0 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - # 0 all, 1 onlyfree - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - title: "{{ .Keywords }}" - search_type: 1 - taglist: "" - tags_type: 0 - - rows: - selector: table#torrent_table > tbody > tr.torrent:has(a[href^="torrents.php?action=download&id="]) - - fields: - category: - selector: td.cats_col div a - attribute: href - filters: - - name: regexp - args: \[(\d+)\] - title: - selector: a[href^="torrents.php?id="] - details: - selector: a[href^="torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description: - selector: div.tags - poster: - selector: td:nth-child(2) script - filters: - - name: regexp - args: "src=(.*?)>" - - name: re_replace - args: ["\\\\", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) - filters: - - name: replace - args: ["Never", "99 years ago"] - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# Engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/52bt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/52bt.yml deleted file mode 100644 index 6f00c155..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/52bt.yml +++ /dev/null @@ -1,118 +0,0 @@ ---- -id: 52bt -name: 52BT -description: "52BT is a CHINESE Public tracker for MOVIES / TV / MUSIC / GENERAL" -language: zh-CN -type: public -encoding: UTF-8 -requestDelay: 2 -links: - # Send any content to 52btbtbt@gmail.com to get the latest address. or visit https://52btbt.icu/ - - https://www.529072.xyz/ - - https://www.529073.xyz/ -legacylinks: - - https://www.529056.xyz/ - - https://www.529058.xyz/ - - https://www.52bt059.buzz/ - - https://www.529060.xyz/ - - https://www.529061.xyz/ - - https://www.529059.xyz/ - - https://www.529062.xyz/ - - https://www.529063.xyz/ - - https://www.529064.xyz/ - - https://www.529066.xyz/ - - https://www.529065.xyz/ - - https://www.529068.xyz/ - - https://www.529069.xyz/ - - https://www.529067.xyz/ - - https://www.529070.xyz/ - - https://www.529071.xyz/ - -caps: - categorymappings: - - {id: 影视, cat: TV, desc: 影视} - - {id: 影视, cat: Movies, desc: 影视} - - {id: 音乐, cat: Audio, desc: 音乐} - - {id: 图像, cat: Other, desc: 图像} - - {id: 文档书籍, cat: Books, desc: 文档书籍} - - {id: 压缩文件, cat: Other, desc: 压缩文件} - - {id: 安装包, cat: PC, desc: 安装包} - - {id: 其他, cat: Other, desc: 其他} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cat-id - type: select - label: Category - default: 0 - options: - 0: All categories - 1: Film and Television - 2: Music - 3: Images - 4: Books - 5: Zip Files - 6: Software - 7: Other - - name: sort - type: select - label: Sort requested from site - default: 2 - options: - 2: created - 1: size - 0: relevance - - name: info_flaresolverr - type: info_flaresolverr - -search: - paths: - - path: "search-{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}-{{ .Config.cat-id }}-{{ .Config.sort }}-1.html" - - path: "search-{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}-{{ .Config.cat-id }}-{{ .Config.sort }}-2.html" - error: - - selector: :root:contains("Internal Server Error") - - rows: - selector: div.ssbox - - fields: - categorydesc: - selector: div.title > h3 > span - filters: - - name: re_replace - args: ["([\\[\\]]+)", ""] - title: - selector: a[href^="/hash/"] - details: - selector: a[href^="/hash/"] - attribute: href - infohash: - selector: a[href^="magnet:?xt="] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - date: - selector: div.sbar > span:contains("添加时间:") > b - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd zzz" - size: - selector: div.sbar > span:contains("大小:") > b - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/52pt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/52pt.yml deleted file mode 100644 index dfab9bd9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/52pt.yml +++ /dev/null @@ -1,183 +0,0 @@ ---- -id: 52pt -name: 52PT -description: "52PT is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://52pt.site/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 406, cat: Audio/Video, desc: "MusicVideo/音乐MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/运体"} - - {id: 409, cat: Other, desc: "Misc/其他"} - - {id: 410, cat: Other, desc: "Opera/戏曲 曲艺"} - - {id: 408, cat: Audio, desc: "HQ Audio/无损音乐"} - - {id: 411, cat: XXX, desc: "Erotic/情色"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User (junior college) and above will be retained forever
  2. Elite User (junior high school) and above will not be deleted after parking (in the control panel)
  3. Users with a parked account who do not log in for 400 consecutive days will be deleted
  4. Users with non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users with no traffic (that is, upload/download data are both 0) will be deleted if they do not log in for 15 consecutive days, or if the registration time reaches 60 days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="usercp.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # does not return imdb or doubanid in results - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.1 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/Bittorrentfiles.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/Bittorrentfiles.yml deleted file mode 100644 index ab05ff8e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/Bittorrentfiles.yml +++ /dev/null @@ -1,240 +0,0 @@ ---- -id: Bittorrentfiles -name: Bittorrentfiles -description: "Bittorrentfiles is a Private GERMAN Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://bittorrentfiles.me/ -legacylinks: - - https://www.Bittorrentfiles.me/ - -caps: - categorymappings: - # Movie - - {id: 48, cat: Movies/HD, desc: "Movie 1080p"} - - {id: 31, cat: Movies/3D, desc: "Movie 3D"} - - {id: 21, cat: Movies/HD, desc: "Movie 720p"} - - {id: 28, cat: Movies/SD, desc: "Movie AVI"} - - {id: 20, cat: Movies/DVD, desc: "Movie DVD-R"} - - {id: 40, cat: Movies/HD, desc: Movie "h264/x264"} - - {id: 30, cat: Movies/HD, desc: "Movie HD/BD"} - - {id: 49, cat: Movies/Foreign, desc: "Movie International"} - - {id: 78, cat: Movies/UHD, desc: "Movie UHD"} - - {id: 85, cat: Movies/HD, desc: "Movie x265"} - # Serien - - {id: 79, cat: TV/HD, desc: "Staffeln HD"} - - {id: 56, cat: TV/SD, desc: "Staffeln SD"} - - {id: 88, cat: TV/UHD, desc: "Staffeln UHD"} - - {id: 86, cat: TV/UHD, desc: "Folgen UHD"} - - {id: 54, cat: TV/HD, desc: "Folgen HD"} - - {id: 80, cat: TV/HD, desc: "Folgen HD/BD"} - - {id: 7, cat: TV/SD, desc: "Folgen SD"} - - {id: 55, cat: TV/Foreign, desc: "Folgen International"} - # Doku - - {id: 91, cat: TV/Documentary, desc: "Doku UHD"} - - {id: 64, cat: TV/Documentary, desc: "Doku HD"} - - {id: 36, cat: TV/Documentary, desc: "Doku SD"} - - {id: 81, cat: TV/Documentary, desc: "Doku 3D"} - - {id: 82, cat: TV/Documentary, desc: "Doku Packs"} - # Spiele - - {id: 4, cat: PC/Games, desc: "Spiele Windows"} - - {id: 92, cat: PC/Mac, desc: "Spiele Mac"} - - {id: 93, cat: PC, desc: "Spiele Linux"} - - {id: 97, cat: Console/XBox, desc: "Spiele XBOX"} - - {id: 94, cat: PC/Mobile-Other, desc: "Spiele Mobile"} - - {id: 95, cat: Console/NDS, desc: "Spiele Nintendo"} - - {id: 96, cat: Console/PSP, desc: "Spiele Playstation"} - - {id: 42, cat: Console/Other, desc: "Spiele Andere"} - # Musik - - {id: 66, cat: Audio, desc: "Musik Alben"} - - {id: 75, cat: Audio, desc: "Musik Singles"} - - {id: 50, cat: Audio, desc: "Musik Charts"} - - {id: 52, cat: Audio, desc: "Musik Packs"} - - {id: 53, cat: Audio/Video, desc: "Musik Video"} - - {id: 61, cat: Audio/Video, desc: "Musik DVD-R"} - - {id: 83, cat: Audio/Lossless, desc: "Musik Flac"} - - {id: 68, cat: Audio, desc: "Musik Live"} - - {id: 6, cat: Audio, desc: "Musik Misc"} - - {id: 51, cat: Audio, desc: "Musik OST"} - - {id: 67, cat: Audio, desc: "Musik Sampler"} - # App - - {id: 98, cat: PC/0day, desc: "App Windows"} - - {id: 62, cat: PC/Mac, desc: "App Mac OS"} - - {id: 63, cat: PC/0day, desc: "App Linux"} - - {id: 34, cat: PC, desc: "App Andere"} - # Sport - - {id: 74, cat: TV/Sport, desc: "Sport HD"} - - {id: 39, cat: TV/Sport, desc: "Sport SD"} - # Sonstiges - - {id: 41, cat: Audio/Audiobook, desc: "Sonstiges A-Book"} - - {id: 33, cat: TV/Anime, desc: "Sonstiges Anime"} - - {id: 35, cat: Books/EBook, desc: "Sonstiges e-Book-DE"} - - {id: 90, cat: Books/EBook, desc: "Sonstiges e-Book-INT"} - - {id: 65, cat: Other, desc: "Sonstiges E-Learning"} - - {id: 37, cat: PC, desc: "Sonstiges Mobile"} - - {id: 87, cat: Books/Mags, desc: "Sonstiges E-Paper"} - - {id: 38, cat: Other, desc: "Sonstiges Kids"} - - {id: 47, cat: Other, desc: "Sonstiges Line"} - - {id: 9, cat: Other, desc: "Sonstiges Andere"} - # XXX - - {id: 58, cat: XXX/x264, desc: "XXX HD"} - - {id: 46, cat: XXX, desc: "XXX Hentai"} - - {id: 59, cat: XXX/Pack, desc: "XXX Pack"} - - {id: 27, cat: XXX/SD, desc: "XXX SD"} - - {id: 76, cat: XXX, desc: "XXX Andere"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: user - type: text - label: Username - - name: pass - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_account - type: info - label: Account Settings - default: In order to see the correct date and size in the torrent rows, change the Torrentliste: setting to Modern on your account profile. - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts of users, power users and mega users will be permanently deleted from the database after 125 days! Torrents in your share don't count as activity - only logging in again counts! You can extend this period to 250 days by setting your account to parking. You can find the option to do this under Edit Profile." - -login: - path: signin.php - method: post - inputs: - user: "{{ .Config.user }}" - pass: "{{ .Config.pass }}" - returnto: "%2Fbrowse.php" - error: - - selector: .error - test: - path: index.php - selector: a[href^="/logout.php?c="] - -search: - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0=title, 1=descr, 2=nfo, 3=all, 4=imdb - search_where: "{{ if .Query.IMDBID }}3{{ else }}0{{ end }}" - # 0=active, 1=all, 2=onlydead, 4=only upload, 5=doubleup, 6=halfdown, 7=highlight, 8=request - status: "{{ if .Config.freeleech }}4{{ else }}1{{ end }}" - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: table.tableinborder > tbody > tr:has(a[href^="/download.php"]) - - fields: - category: - selector: a[href^="/browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php"] - attribute: title - details: - selector: a[href^="details.php"] - attribute: href - download: - selector: a[href^="/download.php"] - attribute: href - grabs: - selector: a[title="Snatcher"] - optional: true - default: 0 - files: - selector: a[title="Files"] - optional: true - default: 0 - size: - selector: td:nth-child(6) - seeders: - selector: a[title="Seeder"] - optional: true - default: 0 - leechers: - selector: a[title="Leecher"] - optional: true - default: 0 - # 2 flavours of dates - date_day: - # Heute 13:30:04 - # Gestern 09:10:10 - selector: td:nth-child(10):contains("Heute"), td:nth-child(10):contains("Gestern") - optional: true - filters: - - name: split - args: ["b", 0] - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: "+01:00" # CET - date_year: - # 30.02.2018 23:12:50 - selector: td:nth-child(10):contains("."):contains(":") - optional: true - filters: - - name: split - args: ["b", 0] - - name: append - args: "+01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img[alt="OU"]: 0 - img[alt="D/2"]: 0.5 - img[alt="D / 2"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[alt="2xU"]: 2 - "*": 1 - minimumratio: - text: 0.8 - minimumseedtime: - # 60 hours (as seconds = 60 x 60 x 60) - text: 216000 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/abnormal-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/abnormal-api.yml deleted file mode 100644 index 17f1cc47..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/abnormal-api.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: abnormal-api -name: Abnormal (API) -description: "ABNormal (ABN) is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -requestDelay: 2.1 -links: - - https://abn.lol/ -legacylinks: - - https://abnormal.ws - -caps: - categorymappings: - - {id: 1, cat: TV, desc: "Series"} - - {id: 2, cat: Movies, desc: "Movies"} - - {id: 3, cat: TV/Documentary, desc: "Documentaries"} - - {id: 4, cat: TV/Anime, desc: "Anime"} - - {id: 5, cat: PC/Games, desc: "Games"} - - {id: 6, cat: PC, desc: "Applications"} - - {id: 7, cat: Books/EBook, desc: "Ebooks"} - - {id: 9, cat: TV, desc: "Emissions"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find your API key on your Parameters page. The API key is read-only and never expires." - - name: apiurl - label: API URL - type: text - default: api.abn.lol - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: sort - type: select - label: Sort requested from site - default: Created - options: - Created: created - Seeders: seeders - Size: size - ReleaseName: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts are automatically deactivated after 4 months of inactivity (no connection to the site)." - -login: - path: "https://{{ .Config.apiurl }}/api/Release/Search?name=test&resultByPage=1" - method: get - error: - - selector: :root:contains("Unauthorized") - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - -search: - paths: - - path: "https://{{ .Config.apiurl }}/api/Release/Search" - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - $raw: "{{ range .Categories }}categoryId={{.}}&{{end}}" - name: "{{ .Keywords }}" - resultByPage: 100 - sort: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - freeleech: "{{ if .Config.freeleech }}true{{ else }}{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: $ - - fields: - category: - selector: categoryId - title_phase1: - selector: name - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - _id: - selector: id - details: - # api returns https://api.abn.lol/api/Release/671444 which is not valid for browsing - text: "{{ .Config.sitelink }}Torrent/Details?ReleaseId={{ .Result._id }}" - download: - selector: downloadUrl - infohash: - selector: infoHash - date: - # created": "2026-03-07T06:59:22.137408" is returned by Newtonsoft.Json.Linq as 03/07/2026 06:59:22 - selector: created - filters: - - name: append - args: " +00:01" # CET - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: completed - downloadvolumefactor: - selector: freeleech - case: - False: 1 # not free - True: 0 # freeleech - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 4 days (as seconds = 4 x 24 x 60 x 60) - text: 345600 -# JSON diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/abtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/abtorrents.yml deleted file mode 100644 index 156e8ce8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/abtorrents.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: abtorrents -replaces: - - audiobooktorrents -name: ABtorrents -description: "ABtorrents (ABT) is a Private Torrent Tracker for AUDIOBOOKS" -language: en-US -type: private -encoding: UTF-8 -links: - - https://usefultrash.net/ -legacylinks: - - https://abtorrents.me/ - - https://abtorrents.xyz/ - -caps: - categorymappings: - - {id: 10, cat: Audio/Audiobook, desc: "Action & Adventure"} - - {id: 80, cat: Audio/Audiobook, desc: "Adult & Erotica"} - - {id: 20, cat: Audio/Audiobook, desc: "Biography & Memoirs"} - - {id: 30, cat: Audio/Audiobook, desc: "Business"} - - {id: 40, cat: Audio/Audiobook, desc: "Childrens"} - - {id: 70, cat: Audio/Audiobook, desc: "Computers"} - - {id: 260, cat: Audio/Audiobook, desc: "Crime & Thriller"} - - {id: 90, cat: Audio/Audiobook, desc: "Fantasy-General"} - - {id: 100, cat: Audio/Audiobook, desc: "Fantasy-Youth"} - - {id: 130, cat: Audio/Audiobook, desc: "General Fiction"} - - {id: 140, cat: Audio/Audiobook, desc: "Historical Fiction"} - - {id: 150, cat: Audio/Audiobook, desc: "History"} - - {id: 160, cat: Audio/Audiobook, desc: "Horror"} - - {id: 50, cat: Audio/Audiobook, desc: "Humor (Comedy)"} - - {id: 170, cat: Audio/Audiobook, desc: "Literature"} - - {id: 175, cat: Audio/Audiobook, desc: "LitRPG"} - - {id: 180, cat: Audio/Audiobook, desc: "Mystery"} - - {id: 215, cat: Audio/Audiobook, desc: "Paranormal Romance"} - - {id: 200, cat: Audio/Audiobook, desc: "Radio Drama"} - - {id: 205, cat: Audio/Audiobook, desc: "Reference & Educational"} - - {id: 207, cat: Audio/Audiobook, desc: "Religion & Spirituality"} - - {id: 210, cat: Audio/Audiobook, desc: "Romance"} - - {id: 240, cat: Audio/Audiobook, desc: "Sci-Fi Apocalypse"} - - {id: 220, cat: Audio/Audiobook, desc: "Science"} - - {id: 230, cat: Audio/Audiobook, desc: "Science Fiction"} - - {id: 250, cat: Audio/Audiobook, desc: "Self Improvement"} - - {id: 265, cat: Audio/Audiobook, desc: "Sports & Fitness"} - - {id: 270, cat: Audio/Audiobook, desc: "Suspense"} - - {id: 280, cat: Audio/Audiobook, desc: "Talk Radio"} - - {id: 285, cat: Audio/Audiobook, desc: "Travel"} - - {id: 290, cat: Audio/Audiobook, desc: "Urban Fantasy & Paranormal"} - - {id: 300, cat: Audio/Audiobook, desc: "Western"} - - {id: 310, cat: Audio/Audiobook, desc: "Young Adult"} - - {id: 400, cat: Books/EBook, desc: "[E] Action & Adventure"} - - {id: 410, cat: Books/EBook, desc: "[E] Adult & Erotica"} - - {id: 420, cat: Books/EBook, desc: "[E] Arts & Crafts (Hobbies)"} - - {id: 430, cat: Books/EBook, desc: "[E] Biography & Memoirs"} - - {id: 435, cat: Books/EBook, desc: "[E] Business"} - - {id: 720, cat: Books/EBook, desc: "[E] Childrens"} - - {id: 450, cat: Books/EBook, desc: "[E] Computer Learning"} - - {id: 460, cat: Books/EBook, desc: "[E] Cookery"} - - {id: 470, cat: Books/EBook, desc: "[E] Crime & Thriller"} - - {id: 480, cat: Books/EBook, desc: "[E] Fantasy"} - - {id: 490, cat: Books/EBook, desc: "[E] Fantasy-Youth"} - - {id: 500, cat: Books/EBook, desc: "[E] General Fiction"} - - {id: 510, cat: Books/EBook, desc: "[E] Historical Fiction"} - - {id: 520, cat: Books/EBook, desc: "[E] History & Documentary"} - - {id: 530, cat: Books/EBook, desc: "[E] Horror"} - - {id: 540, cat: Books/EBook, desc: "[E] Humor (Comedy)"} - - {id: 560, cat: Books/EBook, desc: "[E] Literary Fiction"} - - {id: 565, cat: Books/EBook, desc: "[E] LitRPG"} - - {id: 570, cat: Books/EBook, desc: "[E] Mystery"} - - {id: 590, cat: Books/EBook, desc: "[E] Paranormal Romance"} - - {id: 600, cat: Books/EBook, desc: "[E] Reference & Educational"} - - {id: 610, cat: Books/EBook, desc: "[E] Religion & Spirituality"} - - {id: 620, cat: Books/EBook, desc: "[E] Romance"} - - {id: 630, cat: Books/EBook, desc: "[E] Sci-Fi"} - - {id: 640, cat: Books/EBook, desc: "[E] Sci-Fi & Apocalyptic"} - - {id: 645, cat: Books/EBook, desc: "[E] Science"} - - {id: 650, cat: Books/EBook, desc: "[E] Self-Help"} - - {id: 660, cat: Books/EBook, desc: "[E] Sports & Fitness"} - - {id: 665, cat: Books/EBook, desc: "[E] Suspense"} - - {id: 670, cat: Books/EBook, desc: "[E] Travel"} - - {id: 690, cat: Books/EBook, desc: "[E] Urban Fantasy & Paranormal"} - - {id: 700, cat: Books/EBook, desc: "[E] Western"} - - {id: 710, cat: Books/EBook, desc: "[E] Young Adult"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_results - type: info - label: Search results - default: "If you are getting the error Login Failed, got redirected then access the site with your browser and mark as read all PMs." - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Any account that has unread PMs older than 14 days or has not logged in for 30 days will be suspended
  2. Any account that has not been logged into for 90 days is deemed inactive and will thus be purged
  3. Any account that has not been logged into for 30 days is deemed inactive and will be put on suspension until User logs in again
  4. User’s stats, points, and history are irrevocably lost when purged
  5. Logging into the site is the ONLY way to keep an account active
  6. SEEDING ONLY does NOT constitute site activity.
" - -login: - # using cookie method because login page simplecaptcha generates Exception: Cannot access child value on Newtonsoft.Json.Linq.JValue. - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="logout.php?hash_please="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # title, descr, series, additional_info, author, author, uploader - searchin: title - # 0 active, 1 incldead, 2 onlydead, 3 My Unsatisfied Torrents, 4 My Watch List, 5 My Uploads - incldead: 1 - # 0 All Torrents, 1 Only Free Torrents - only_free: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - - rows: - selector: table.browse.table.striped > tbody > tr:has(a[href^="download.php?torrent="]) - - fields: # some users (rank specific?) have an extra column (td:nth-child(4)) with bookmark features - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - remove: span.has-text-red - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - poster: - selector: img[src^="/img.php?"] - attribute: src - files: - selector: a[href^="filelist.php?id="] - date_day: - # auto adjusted by site account profile - # Yesterday 6:00 AM - selector: td:nth-last-child(2) > div:contains("day") - optional: true - filters: - - name: fuzzytime - date_year: - # auto adjusted by site account profile - # Aug 6 2021 7:25 PM - selector: td:nth-last-child(2) > div:not(:contains("day")) - optional: true - filters: - - name: dateparse - args: "MMM d yyyyh:mm tt" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-last-child(4) div[title^="Size"] - grabs: - selector: a[href^="snatches.php?id="] - seeders: - selector: a[title$="Seeders"] - optional: true - default: 0 - leechers: - selector: a[title$="Leechers"] - optional: true - default: 0 - downloadvolumefactor: - case: - tr.freeleech_color: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 14 days (as seconds = 14 x 24 x 60 x 60) - text: 1209600 -# U-232 V4.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/acgrip.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/acgrip.yml deleted file mode 100644 index 78ea2ebf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/acgrip.yml +++ /dev/null @@ -1,56 +0,0 @@ ---- -id: acgrip -name: ACG.RIP -description: "ACG.RIP is a CHINESE Public torrent tracker for the latest anime and Japanese related torrents" -language: zh-CN -type: public -followredirect: true -encoding: UTF-8 -links: - - https://acg.rip/ - -caps: - categories: - 1: TV - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: [] - -search: - paths: - - path: / - inputs: - term: "{{ .Keywords }}" - - rows: - selector: tbody tr - - fields: - category: - text: 1 - title: - selector: td.title > span.title a - details: - selector: td.title > span.title a - attribute: href - download: - selector: td.action a - attribute: href - size: - selector: td.size - seeders: - text: 1 - leechers: - text: 1 - date: - # unix - selector: td.date time - attribute: datetime - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/acrossthetasman.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/acrossthetasman.yml deleted file mode 100644 index 7dfb83fa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/acrossthetasman.yml +++ /dev/null @@ -1,92 +0,0 @@ ---- -id: acrossthetasman -name: Across The Tasman -description: "ATT is a Private torrent site for Rugby and other sports played in Australia" -language: en-AU -type: private -encoding: UTF-8 -links: - - https://acrossthetasman.com/ - -caps: - categories: - 1: TV/Sport - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - filename: title - size: size - seeders: seeders - - name: order - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - method: post - path: login.php - inputs: - vb_login_username: "{{ .Config.username }}" - vb_login_password: "{{ .Config.password }}" - cookieuser: 1 - do: login - error: - - selector: div.restore:contains("invalid username or password") - test: - path: torrents.php - selector: a[href*="login.php?do=logout"] - -search: - paths: - - path: torrents.php - inputs: - sort: "{{ .Config.sort }}" - order: "{{ .Config.order }}" - query: "{{ .Keywords }}" - - rows: - selector: table[width='100%'][align='center']:not([class]) > tbody > tr - - fields: - category: - text: 1 - date: - # Feb 12, 2021 - selector: td:nth-child(1) - filters: - - name: dateparse - args: "MMM dd, yyyy" - title: - selector: td:nth-child(4) > span:nth-child(1) - filters: - - name: re_replace - args: ["[\\s]+Uploaded.*", ""] - download: - selector: a[href*="attachment.php?attachmentid="] - attribute: href - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - size: - selector: td:nth-child(10) -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aftershock.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aftershock.yml deleted file mode 100644 index 82f955b7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aftershock.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: aftershock -name: Aftershock -description: "Aftershock is a HUNGARIAN Private Torrent Tracker for MOVIES / GENERAL" -language: hu-HU -type: private -encoding: iso-8859-1 -links: - - https://aftershock-tracker.net/ -legacylinks: - - https://aftershock-tracker.eu/ - -caps: - categorymappings: - - {id: 7, cat: Movies/SD, desc: "XvidEng"} - - {id: 2, cat: Movies, desc: "AfterShock Release"} - - {id: 190, cat: Movies/DVD, desc: "DVD-9Eng"} - - {id: 191, cat: Movies/DVD, desc: "DVD-9Hun"} - - {id: 1, cat: Movies/DVD, desc: "DVDEng"} - - {id: 4, cat: Movies/DVD, desc: "DVDHun"} - - {id: 23, cat: Books/EBook, desc: "E-Book"} - - {id: 3, cat: Movies, desc: "Film Pack"} - - {id: 185, cat: Audio/Lossless, desc: "FLACeng"} - - {id: 188, cat: Audio/Lossless, desc: "FLAChu"} - - {id: 173, cat: Audio/Audiobook, desc: "Hangoskönyv"} - - {id: 34, cat: Movies/HD, desc: "HDEng"} - - {id: 33, cat: Movies/HD, desc: "HDHun"} - - {id: 182, cat: XXX/x264, desc: "HDXXX"} - - {id: 12, cat: PC/Games, desc: "JátékIso"} - - {id: 130, cat: Console, desc: "JátékKonzol"} - - {id: 38, cat: Console/PSP, desc: "JátékPS2"} - - {id: 44, cat: Console, desc: "Jatekrip"} - - {id: 131, cat: Console/XBox, desc: "JátékX-box"} - - {id: 140, cat: Other, desc: "Képek"} - - {id: 141, cat: Other, desc: "Klipek"} - - {id: 154, cat: Other, desc: "MeseEng"} - - {id: 155, cat: Other, desc: "MeseHun"} - - {id: 176, cat: Other, desc: "Mikro HDEng"} - - {id: 174, cat: Other, desc: "Mikro HDHun"} - - {id: 37, cat: PC/Mobile-Other, desc: "MobilPDA"} - - {id: 35, cat: PC, desc: "ProgramEgyéb"} - - {id: 170, cat: PC/ISO, desc: "ProgramIso"} - - {id: 24, cat: TV, desc: "Sorozat Eng"} - - {id: 25, cat: TV, desc: "Sorozat Hun"} - - {id: 167, cat: Movies/SD, desc: "Xvid CamEng"} - - {id: 166, cat: Movies/SD, desc: "Xvid CamHun"} - - {id: 9, cat: Movies/SD, desc: "XvidHun"} - - {id: 169, cat: XXX/ImageSet, desc: "XXX Képek"} - - {id: 39, cat: XXX, desc: "XXXFilm"} - - {id: 28, cat: Audio, desc: "ZeneEng"} - - {id: 29, cat: Audio, desc: "ZeneHun"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 6: seeders - 4: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You will be immediately banned if you have not logged in for more than 6 weeks." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.text:contains("Hiba") - message: - selector: td.text - test: - path: / - selector: a[href*="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech, 4 2x, 5 server - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table tbody#torrent_background tr:has(a.index):has(span.icon_check_alt) - filters: - - name: andmatch - - fields: - category: - selector: a[href*="/browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a.index - attribute: href - filters: - - name: querystring - args: name - - name: urldecode - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a.index - attribute: href - filters: - - name: urldecode - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - - name: replace - args: ["\"", ""] - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: span.box - description: - text: "{{ .Result.genre }}" - files: - selector: td:nth-last-child(6) - size: - selector: td:nth-last-child(5) - seeders: - selector: td:nth-last-child(4) - leechers: - selector: td:nth-last-child(3) - grabs: - selector: td:nth-last-child(2) - date: - selector: td font - filters: - - name: re_replace - args: ["\\s", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - case: - span.icon_gift: 0 - "*": 1 - uploadvolumefactor: - case: - span.icon_upload: 2 - "*": 1 - minimumratio: - text: 0.31 -# XBT v3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/afun.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/afun.yml deleted file mode 100644 index 075d851e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/afun.yml +++ /dev/null @@ -1,180 +0,0 @@ ---- -id: afun -name: AFUN -description: "AFUN is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.ptlover.cc/ - -caps: - categorymappings: - - {id: 407, cat: TV/Sport, desc: "Sports/赛事"} - - {id: 414, cat: TV, desc: "TV Shows/综艺"} - - {id: 408, cat: Audio, desc: "HQ Audio/HQ音频"} - - {id: 409, cat: Other, desc: "Misc/其他"} - - {id: 407, cat: TV/Sport, desc: "TV Sport/体育"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/MV"} - - {id: 403, cat: TV, desc: "TV Talk Shows/脱口秀"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 405, cat: TV/Anime, desc: "Anime/动画"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts will be deleted based on the following rules:
  1. Veteran User or above will never be deleted.
  2. Elite User or above will never be deleted if parked (at User CP).
  3. Parked accounts will be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts will be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 will be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # searching in description causes HTTP 500 so we will not support doubanid searching - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0: include dead, 1: alive, 2: dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0: title, 1: description, 3: uploader, 4: imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0: AND, 2: exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # Support in NexusPHP so bots don't pollute trending searches: - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# NexusPHP v1.9.0 2025-02-06 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/agsvpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/agsvpt.yml deleted file mode 100644 index 717300f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/agsvpt.yml +++ /dev/null @@ -1,219 +0,0 @@ ---- -id: agsvpt -name: AGSVPT -description: "AGSVPT (Arctic Global Seed Vault) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.agsvpt.com/ - - https://pt.agsvpt.cn/ -legacylinks: - - https://abroad.agsvpt.com/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "Movie(电影)", default: true} - - {id: 402, cat: TV, desc: "TV Series(电视剧)", default: true} - - {id: 403, cat: TV, desc: "TV Shows(综艺)", default: true} - - {id: 405, cat: TV/Anime, desc: "Anime(动漫)", default: true} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(纪录片)", default: true} - - {id: 419, cat: TV, desc: "Playlet(短剧)", default: true} - - {id: 411, cat: Audio, desc: "Music(音乐))", default: true} - - {id: 406, cat: Audio/Video, desc: "MV(演唱", default: true} - - {id: 407, cat: TV/Sport, desc: "Sports(体育)", default: true} - - {id: 408, cat: Audio/Audiobook, desc: "Audio(音频)", default: true} - - {id: 413, cat: PC/Games, desc: "Game(游戏)", default: false} - - {id: 416, cat: Books/Comics, desc: "Comic(漫画)", default: false} - - {id: 412, cat: PC/0day, desc: "Software(软件)", default: false} - - {id: 418, cat: Other, desc: "Picture(图片)", default: false} - - {id: 415, cat: Books/EBook, desc: "E-Book(电子书/有声书)", default: false} - - {id: 417, cat: Other, desc: "Education(学习资料)", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the AGSVPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the 每页种子数:每页显示(Torrents per page:) setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Elite User and above will not have their account deleted after parking (in the Control Panel)
  2. Users who do not log in for 400 consecutive days will be disabled.
  3. Users with a parked account will be disabled if they do not log in for 150 consecutive days
  4. Users who have no traffic (i.e., uploading/downloading data is 0) within 7 days of new registration will be disabled
  5. Users with no traffic (i.e. both upload/download data is 0) who do not log in for 30 consecutive days will be disabled.
" - -login: - path: takelogin.php - method: post - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 403, 405, 404, 419, 411, 406, 407, 408] - - path: special.php - categories: [413, 416, 412, 418, 415, 417] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description_verified: - case: - span[title="通过"]: "Verified:" - span[title="未审"]: "Unverified:" - span[title="拒绝"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.12 2024-04-05 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aidoruonline.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aidoruonline.yml deleted file mode 100644 index 46407898..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aidoruonline.yml +++ /dev/null @@ -1,136 +0,0 @@ ---- -id: aidoruonline -name: Aidoru!Online -description: "Aidoru!Online is a JAPANESE Private Torrent Tracker for Female Japanese Idol related files" -language: en-US -type: private -encoding: UTF-8 -links: - - https://aidoru-online.me/ -legacylinks: - - https://aidoru-online.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "BD/DVDISO"} - - {id: 2, cat: Movies, desc: "BD/DVD-RIP"} - - {id: 3, cat: TV, desc: "TV"} - - {id: 4, cat: TV, desc: "Perf"} - - {id: 5, cat: TV, desc: "PV"} - - {id: 6, cat: TV, desc: "Webstream"} - - {id: 7, cat: Other, desc: "Image"} - - {id: 8, cat: Audio, desc: "Audio"} - - {id: 9, cat: Audio, desc: "Album"} - - {id: 10, cat: Audio, desc: "Single"} - - {id: 11, cat: Audio, desc: "Radio"} - - {id: 12, cat: Other, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and check that your ratio is not below 0.8, otherwise the site will not return download links to the indexer. This does not apply to freeleech torrents." - -login: - # using cookie method because login page returns 0 bytes with a cookie flog=6 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href^="torrents-details.php?id="] - -search: - paths: - # https://aidoru-online.me/get_ttable.php?pcat=Show+All&scat=3%2C4%2C5%2C6&subbed=&fl=&resd=&p=0&searchstr=peace&deadlive=0&sortcol=id&sortorder=desc&startdt=&enddt= - - path: get_ttable.php - inputs: - pcat: "Show+All" - scat: "{{ join .Categories \",\" }}" - subbed: "" - fl: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - resd: "" - p: 0 - searchstr: "{{ .Keywords }}" - deadlive: 1 - sortcol: "{{ .Config.sort }}" - sortorder: "{{ .Config.type }}" - startdt: "" - enddt: "" - # does not support imdbid search and does not return imdb link in results - - rows: - selector: table.ttable_headinner tr.t-row:has(a[href^="download.php?id="]) - - fields: - categorydesc: - selector: a.category-link - filters: - - name: split - args: [" ", 1] - title: - selector: a[href^="torrents-details.php?id="] - attribute: title - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - size: - selector: td.ttable_size - seeders: - selector: td:nth-last-child(4) - leechers: - selector: td:nth-last-child(3) - grabs: - selector: td:nth-last-child(2) - date: - selector: td:last-child - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyMMdd HH:mm:ss" - downloadvolumefactor: - case: - img[src="images/freeleech.png"]: 0 # dl not counted - img[src="images/freeleech2.png"]: 0 # dl not counted & upload 2x - "*": 1 - uploadvolumefactor: - case: - img[src="images/freeleech2.png"]: 2 - "*": 1 - minimumratio: - text: 0.8 -# TorrentTrader v2-svn diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aither-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aither-api.yml deleted file mode 100644 index 6af509c5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aither-api.yml +++ /dev/null @@ -1,195 +0,0 @@ ---- -id: aither-api -name: Aither (API) -description: "Aither is a Private Torrent Tracker for HD MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -# status https://status.aither.cc/ -links: - - https://aither.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 9, cat: TV/Sport, desc: "Sport"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - book-search: [q] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Aither account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in to the Aither web site with your browser once every90 days to prevent your account from getting disabled." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 5 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/alingpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/alingpt.yml deleted file mode 100644 index 5834bf24..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/alingpt.yml +++ /dev/null @@ -1,178 +0,0 @@ ---- -id: alingpt -name: alingPT -description: "alingPT is a CHINESE Private Torrent Tracker for MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.aling.de/ - -caps: - categorymappings: - - {id: 405, cat: TV/Anime, desc: "Animations/动画"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 404, cat: TV/Documentary, desc: "Documentary/纪录片"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Accounts parked will not be deleted
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 200 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 60 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.10.1 2026-02-17 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/amigosshare.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/amigosshare.yml deleted file mode 100644 index a133ab78..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/amigosshare.yml +++ /dev/null @@ -1,250 +0,0 @@ ---- -id: amigosshare -name: Amigos Share Club -description: "Amigos Share Club (ASC) is a BRAZILIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pt-BR -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://cliente.amigos-share.club/ -legacylinks: - - http://amigos-share.club/ - - https://amigos-share.club/ - -caps: - categorymappings: - - {id: 75, cat: XXX, desc: "Adultos: Hentai"} - - {id: 107, cat: XXX, desc: "Adultos: Gay"} - - {id: 108, cat: XXX, desc: "Adultos: Transex/Female"} - - {id: 74, cat: XXX, desc: "Adultos: XXX"} - - {id: 146, cat: XXX, desc: "Adultos: OnlyFans"} - - {id: 69, cat: TV/Anime, desc: "Anime: Anime"} - - {id: 116, cat: TV/Anime, desc: "Anime: Filmes"} - - {id: 118, cat: TV/Anime, desc: "Anime: Séries"} - - {id: 21, cat: PC/0day, desc: "Aplicativos: Windows"} - - {id: 28, cat: PC/Mobile-iOS, desc: "Aplicativos: iPod"} - - {id: 27, cat: PC/Mobile-iOS, desc: "Aplicativos: iPad"} - - {id: 26, cat: PC/Mobile-iOS, desc: "Aplicativos: iPhone"} - - {id: 25, cat: PC, desc: "Aplicativos: Linux"} - - {id: 23, cat: PC/Mobile-Android, desc: "Aplicativos: Android"} - - {id: 22, cat: PC/Mac, desc: "Aplicativos: Mac"} - - {id: 73, cat: Other, desc: "Cursos: Video Aula"} - - {id: 72, cat: Other, desc: "Cursos: Apostila"} - - {id: 71, cat: Other, desc: "Cursos: Cursos"} - - {id: 67, cat: Books/EBook, desc: "Ebook: Livros"} - - {id: 112, cat: Books/Comics, desc: "Ebook: HQs"} - - {id: 147, cat: Books/Comics, desc: "Ebook: Mangas"} - - {id: 121, cat: Audio/Audiobook, desc: "Ebook: Audio-book"} - - {id: 68, cat: Books/Mags, desc: "Ebook: Revistas"} - - {id: 119, cat: Movies, desc: "Filmes:"} - - {id: 70, cat: XXX, desc: "Fotos: Revistas, Livros e Fotos"} - - {id: 51, cat: Console/PS3, desc: "Jogos: Ps3"} - - {id: 54, cat: Console/XBox 360, desc: "Jogos: Xbox360"} - - {id: 109, cat: Console, desc: "Jogos: Emulação"} - - {id: 110, cat: Console, desc: "Jogos: Nintendo Switch"} - - {id: 52, cat: Console, desc: "Jogos: Dreamcast"} - - {id: 50, cat: Console, desc: "Jogos: Ps2"} - - {id: 49, cat: Console, desc: "Jogos: Ps1"} - - {id: 48, cat: PC/Mac, desc: "Jogos: Mac"} - - {id: 55, cat: Console/Wii, desc: "Jogos: Wii"} - - {id: 82, cat: Console/PSP, desc: "Jogos: PSP"} - - {id: 47, cat: PC/Games, desc: "Jogos: Pc"} - - {id: 61, cat: Console, desc: "Jogos: Emuladores e Roms"} - - {id: 58, cat: Console/NDS, desc: "Jogos: Nintendo DS"} - - {id: 57, cat: Console, desc: "Jogos: Android"} - - {id: 56, cat: Console/XBox, desc: "Jogos: Xbox"} - - {id: 78, cat: Console/XBox One, desc: "Jogos: Xbox One"} - - {id: 79, cat: Console/PS4, desc: "Jogos: Ps4"} - - {id: 129, cat: Other, desc: "Outros: Fonts"} - - {id: 128, cat: Other, desc: "Outros: Scripts"} - - {id: 127, cat: Other, desc: "Outros: Add-ons"} - - {id: 130, cat: Other, desc: "Outros: MOD"} - - {id: 125, cat: Other, desc: "Outros: Pack de Imagens"} - - {id: 124, cat: Other, desc: "Outros: Vetores"} - - {id: 122, cat: Other, desc: "Outros: Plug-ins"} - - {id: 126, cat: Other, desc: "Outros: Extensões"} - - {id: 120, cat: TV, desc: "Series:"} - - {id: 64, cat: TV, desc: "Tv: Fechada"} - - {id: 62, cat: TV/Sport, desc: "Tv: Esportes"} - - {id: 63, cat: TV, desc: "Tv: Aberta"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_results - type: info - label: "Search results" - default: "Set Exibição De Torrents to Lista in your Minhas Configurações.
Using Capas is not supported and will return 0 results." - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts, that is, without access for 90 days (3 months), will be automatically deleted by the system; with no possibility of reactivation." - -login: - path: account-login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - remember: yes - error: - - selector: div.alert-error - test: - path: torrents-search.php - selector: a[href="account-logout.php"] - -search: - paths: - # http://amigos-share.club/torrents-search.php?search=&sort=id&order=desc&page=1 - - path: torrents-search.php - # site displays just 20 results, no profile options found to change this, so we attempt to fetch 5 pages - - path: torrents-search.php - inputs: - page: 1 - - path: torrents-search.php - inputs: - page: 2 - - path: torrents-search.php - inputs: - page: 3 - - path: torrents-search.php - inputs: - page: 4 - inputs: - # note: site does not support multi cat search. - # replace spaces with wildcard (percent symbol) as site's default search is exact string. - search: "{{ re_replace .Keywords \"[\\s]+\" \"%\" }}" - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # torrents-search.php does not support imdbid search or return imdb links. - keywordsfilters: - # drop the year from searches since site titles do not include year - - name: re_replace - args: ["(\\s*\\b((19|20)\\d{2})\\b)", ""] - - rows: - selector: "div#fancy-list-group ul.list-group li.list-group-item{{ if .Config.freeleech }}:has(span.badge-success:contains(\"FREE\")){{ else }}{{ end }}" - - fields: - category_default: - # tv and movie just have an image :-b - selector: div.list-group-item-addon img - attribute: src - optional: true - case: - "[src$=\"/filmes2.fw.png\"]": 119 - "[src$=\"/seriados.fw.png\"]": 120 - category: - # while the rest have a href with a cat number \o/ - selector: a[href*="cat="] - attribute: href - optional: true - default: "{{ .Result.category_default }}" - filters: - - name: querystring - args: cat - _quality: - selector: div.list-group-item-content p.m-0 span.badge-primary:contains("1080p"), div.list-group-item-content p.m-0 span.badge-primary:contains("720p"), div.list-group-item-content p.m-0 span.badge-primary:contains("4k") - optional: true - filters: - - name: replace - args: ["4k", "2160p"] - _year: - selector: div.list-group-item-content p.m-0 span.badge-primary[style$="#246AB6;"] - optional: true - _type: - selector: div.list-group-item-content p.m-0 span.badge-info:contains("Rip"), div.list-group-item-content p.m-0 span.badge-info:contains("WEB-"), div.list-group-item-content p.m-0 span.badge-info:contains("TV"), div.list-group-item-content p.m-0 span.badge-info:contains("Blu-Ray"), div.list-group-item-content p.m-0 span.badge-info:contains("BD50"), div.list-group-item-content p.m-0 span.badge-info:contains("MUX"), div.list-group-item-content p.m-0 span.badge-info:contains("DVD"), div.list-group-item-content p.m-0 span.badge-info:contains("320"), div.list-group-item-content p.m-0 span.badge-info:contains("CAM"), div.list-group-item-content p.m-0 span.badge-info:contains("rip") - optional: true - _language: - selector: div.list-group-item-content p.m-0 span.badge-primary[style$="#b6249d;"] - optional: true - title: - selector: a[href*="torrents-details.php?id="], a[href*="details-misc.php?id="] - filters: - # strip out the non-english title [to the left of the brackets] and drop the brackets enclosing the english title - - name: re_replace - args: ["^(.*?)[\\(](.*?)[\\)](.*?)$", "$2$3"] - # add the year to the title - - name: append - args: "{{ if .Result._year }} {{ .Result._year }}{{ else }}{{ end }}" - # add the quality to the title - - name: append - args: "{{ if .Result._quality }} {{ .Result._quality }}{{ else }}{{ end }}" - # add the type to the title - - name: append - args: "{{ if .Result._type }} {{ .Result._type }}{{ else }}{{ end }}" - # add audio to the title - - name: append - args: "{{ if .Result._language }} {{ .Result._language }}{{ else }}{{ end }}" - - name: re_replace - args: ["(Dual|[Nn]acional|[Dd]ublado)", "Brazilian $1"] - details: - selector: a[href*="torrents-details.php?id="], a[href*="details-misc.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - date: - selector: p:contains("Lançado:") - # auto adjusted by site account profile - optional: true - default: now - filters: - - name: regexp - args: "Lançado: (.+?)$" - - name: re_replace - args: [" (\\d:)", " 0$1"] - - name: dateparse - args: "dd/MM/yy HH:mm:ss" - size: - selector: div.list-group-item-content p.m-0 span.badge-info - seeders: - selector: div.list-group-item-controls a:nth-child(1) - leechers: - selector: div.list-group-item-controls a:nth-child(2) - grabs: - selector: div.list-group-item-controls a:nth-child(3) - genre: - selector: div.list-group-item-content p.m-0 span.badge-primary[style$="#1c38c2;"] - description: - text: "{{ .Result.genre }}" - downloadvolumefactor: - case: - "span.badge-success:contains(\"FREE\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animebybelka.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animebybelka.yml deleted file mode 100644 index 654b1038..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animebybelka.yml +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: animebybelka -name: Anime by Belka -description: "Anime by Belka (Аниме от Белки) is a RUSSIAN Semi-Private Torrent Tracker for ANIME" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://anibelka.com/ - -caps: - categorymappings: -# - {id: 1, cat: TV/Anime, desc: "Трекер"} -# - {id: 31, cat: TV/Anime, desc: "Аниме архив"} - - {id: 32, cat: TV/Anime, desc: "Аниме универсальные"} - - {id: 33, cat: TV/Anime, desc: "Аниме с озвучкой"} - - {id: 34, cat: TV/Anime, desc: "Аниме с субтитрами"} - - {id: 36, cat: Movies/Other, desc: "Полнометражки"} - - {id: 37, cat: TV/Anime, desc: "Аниме для PSP"} - - {id: 39, cat: TV/Anime, desc: "Сэмплы"} - - {id: 7, cat: Audio, desc: "Аудио"} - - {id: 8, cat: Books/Comics, desc: "Манга"} - - {id: 21, cat: Other, desc: "Изображения аниме"} - - {id: 9, cat: Other, desc: "Япония"} -# - {id: 17, cat: Other, desc: "Информация"} -# - {id: 40, cat: Other, desc: "Аниме онлайн"} -# - {id: 41, cat: Other, desc: "Галерея Японии"} -# - {id: 42, cat: Other, desc: "Фанфики по аниме"} -# - {id: 43, cat: Other, desc: "Отзывы, просьбы, предложения, техника"} - - {id: 52, cat: TV/Anime, desc: "Альтернативные части"} -# - {id: 49, cat: Other, desc: "Форум"} -# - {id: 50, cat: Other, desc: "Аниме. Форум. Обсуждение"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: t - options: - t: created - ts: seeders - sz: size - i: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - -login: - # using cookie method because site returns SQL error on first access - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href^="./ucp.php?mode=logout&sid="] - -search: - paths: - # https://anibelka.com/search.php?tracker_search=torrent&keywords=nyan&terms=all&author=&fid%5B%5D=31&sc=1&sf=titleonly&sk=t&sd=d&sr=topics&st=0&ch=300&t=0 - - path: search.php - inputs: - $raw: "{{ range .Categories }}&fid[]={{.}}{{end}}" - tracker_search: "{{ if .Keywords }}torrent{{ else }}tracker{{ end }}" - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - terms: all - sc: 1 - sf: titleonly - sr: topics - sk: "{{ .Config.sort }}" - sd: "{{ .Config.type }}" - st: 0 - ch: 300 - t: 0 - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: tr:has(a[href^="./download/file.php?id="]) - - fields: - category: - selector: a[href^="./viewforum.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.topictitle - filters: - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a.topictitle - attribute: href - download: - selector: a[href^="./download/file.php?id="] - attribute: href - size: - selector: p b - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: span.seed - leechers: - selector: span.leech - grabs: - selector: span.complet - optional: true - date_today: - # Сегодня, 09:09 - # Вчера, 22:47 - selector: td:nth-last-child(1) p:contains("Сегодня"), td:nth-last-child(1) p:contains("Вчера") - optional: true - filters: - - name: replace - args: ["Сегодня", "Today"] - - name: replace - args: ["Вчера", "Yesterday"] - - name: fuzzytime - date_year: - # 12 янв 2019, 11:26 - selector: td:nth-last-child(1) p:not(:contains("Сегодня")), td:nth-last-child(1) p:not(:contains("Вчера")) - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: ["янв", "Jan"] - - name: replace - args: ["фев", "Feb"] - - name: replace - args: ["мар", "Mar"] - - name: replace - args: ["апр", "Apr"] - - name: replace - args: ["май", "May"] - - name: replace - args: ["июн", "Jun"] - - name: replace - args: ["июл", "Jul"] - - name: replace - args: ["авг", "Aug"] - - name: replace - args: ["сен", "Sep"] - - name: replace - args: ["окт", "Oct"] - - name: replace - args: ["ноя", "Nov"] - - name: replace - args: ["дек", "Dec"] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "dd MMM yyyy, HH:mm zzz" - date: - text: "{{ if or .Result.date_today .Result.date_year }}{{ or .Result.date_today .Result.date_year }}{{ else }}now{{ end }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: td:nth-last-child(5) - remove: a, b, p, span -# ppkBB3cker v.2 (phpBB) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animelayer.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animelayer.yml deleted file mode 100644 index 563592f5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animelayer.yml +++ /dev/null @@ -1,203 +0,0 @@ ---- -id: animelayer -name: AnimeLayer -description: "AnimeLayer is a RUSSIAN Semi-Private Torrent Tracker for ANIME" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://animelayer.ru/ -legacylinks: - - https://www.animelayer.ru/ - -caps: - categorymappings: - - {id: аниме, cat: TV/Anime, desc: Аниме} - - {id: музыка, cat: Audio, desc: Музыка} - - {id: манга, cat: Books, desc: Манга} - - {id: дорама, cat: TV, desc: Дорама} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: category-id - type: select - label: Category - default: anime - options: - anime: Аниме - music: Музыка - manga: Манга - dorama: Дорама - -login: - path: layer/login/ - method: form - form: form#loginForm - inputs: - login: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.error - test: - path: / - selector: a[href^="/auth/logout/"] - -search: - paths: - - path: "torrents/{{ .Config.category-id }}/" - inputs: - q: "{{ .Keywords }}" - - rows: - selector: ul.torrents-list li.torrent-item:has(a[href^="/torrent/"]) - - fields: - category: - selector: label.category - _resolution: - selector: div.description - filters: - - name: regexp - args: "Разрешение:\\s+\\d{3,4}\\D(\\d{3,4})" - title: - selector: img - attribute: alt - filters: - - name: append - args: "{{ if .Result._resolution }} [{{ .Result._resolution }}p]{{ else }}{{ end }}" - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="/torrent/"] - attribute: href - filters: - - name: append - args: download/ - poster: - selector: img - attribute: data-original - size: - selector: div.info - filters: - - name: split - args: ["|", 2] - date_noyear: - # Добавлен: 18 августа в 21:39 - selector: div.info - filters: - - name: regexp - args: "н:\\s(\\d{1,2} \\D{3,8} в\\s\\d{1,2}:\\d{2})" - - name: replace - args: ["\xA0", " "] - - name: replace - args: ["Обновлён:", ""] - - name: replace - args: ["Добавлен:", ""] - - name: replace - args: [" в ", " "] - - name: replace - args: ["января", "January {{ .Today.Year }}"] - - name: replace - args: ["февраля", "February {{ .Today.Year }}"] - - name: replace - args: ["марта", "March {{ .Today.Year }}"] - - name: replace - args: ["апреля", "April {{ .Today.Year }}"] - - name: replace - args: ["мая", "May {{ .Today.Year }}"] - - name: replace - args: ["июня", "June {{ .Today.Year }}"] - - name: replace - args: ["июля", "July {{ .Today.Year }}"] - - name: replace - args: ["августа", "August {{ .Today.Year }}"] - - name: replace - args: ["сентября", "September {{ .Today.Year }}"] - - name: replace - args: ["октября", "October {{ .Today.Year }}"] - - name: replace - args: ["ноября", "November {{ .Today.Year }}"] - - name: replace - args: ["декабря", "December {{ .Today.Year }}"] - - name: re_replace - args: ["\\s(\\d\\:)", " 0$1"] - date_year: - # Обновлён: 3 октября 2019 в 6:02 - selector: div.info - filters: - - name: regexp - args: "н:\\s(\\d{1,2} \\D{3,8} \\d{4} в\\s\\d{1,2}:\\d{2})" - - name: replace - args: ["\xA0", " "] - - name: replace - args: ["Обновлён:", ""] - - name: replace - args: ["Добавлен:", ""] - - name: replace - args: [" в ", " "] - - name: replace - args: ["января", "January"] - - name: replace - args: ["февраля", "February"] - - name: replace - args: ["марта", "March"] - - name: replace - args: ["апреля", "April"] - - name: replace - args: ["мая", "May"] - - name: replace - args: ["июня", "June"] - - name: replace - args: ["июля", "July"] - - name: replace - args: ["августа", "August"] - - name: replace - args: ["сентября", "September"] - - name: replace - args: ["октября", "October"] - - name: replace - args: ["ноября", "November"] - - name: replace - args: ["декабря", "December"] - - name: re_replace - args: ["\\s(\\d\\:)", " 0$1"] - date: - text: "{{ if or .Result.date_year .Result.date_noyear }}{{ or .Result.date_year .Result.date_noyear }}{{ else }}now{{ end }}" - seeders: - selector: div.info - filters: - - name: split - args: ["|", 0] - leechers: - selector: div.info - filters: - - name: split - args: ["|", 1] - _subtitles: - filters: - - name: regexp - args: "Субтитры:(.+?)\\n" - _language: - filters: - - name: regexp - args: "Язык:(.+?)\\n" - description: - text: "{{ if .Result._subtitles }}Субтитры: {{ .Result._subtitles }}{{ else }}{{ end }} - {{ if .Result._language }}Язык: {{ .Result._language }}{{ else }}{{ end }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animenosekai.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animenosekai.yml deleted file mode 100644 index 156770c7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animenosekai.yml +++ /dev/null @@ -1,122 +0,0 @@ ---- -id: animenosekai -name: Anime No Sekai -description: "Anime No Sekai (ANSK) is a BRAZILIAN Private Torrent Tracker for ANIME" -language: pt-BR -type: private -encoding: windows-1252 -links: - - https://www.ansktracker.com/ - -caps: - categorymappings: - - {id: 3, cat: Movies, desc: "Anime Movie"} - - {id: 2, cat: TV/Anime, desc: "Anime OVA"} - - {id: 1, cat: TV/Anime, desc: "Anime TV"} - - {id: 4, cat: TV, desc: "Doramas"} - - {id: 5, cat: Audio, desc: "Músicas"} - - {id: 6, cat: Other, desc: "Outros"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 5 - options: - 5: created - 1: seeders - 3: size - 6: title - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: no - error: - - selector: table:contains("Aviso!") - test: - path: index.php - selector: a[href$="/logout.php"] - -download: - selectors: - - selector: a[href^="download.php/"] - attribute: href - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - freeleech: "{{ if .Config.freeleech }}1{{else}}{{end}}" - order: "{{ .Config.sort }}" - - rows: - selector: tr#trTorrentRow - - fields: - categorydesc: - selector: td:nth-child(1) img - attribute: alt - title: - selector: a[href*="details.php?id="] - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="details.php?id="] - attribute: href - date: - selector: td:nth-child(6) - filters: - - name: dateparse - args: "dd/MM/yyyy" - size: - selector: td:nth-child(7) - files: - selector: td:nth-child(4) - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - font[color="green"]:contains("[FL]"): 0 - "*": 1 - uploadvolumefactor_custom: - selector: font[color="red"]:contains("[x") - optional: true - filters: - - name: replace - args: ["x[", ""] - - name: replace - args: ["]", ""] - uploadvolumefactor: - case: - font[color="red"]:contains("[x"): "{{ .Result.uploadvolumefactor_custom }}" - "*": 1 - minimumratio: - text: 0.25 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animetorrentsro.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animetorrentsro.yml deleted file mode 100644 index 307fc88d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animetorrentsro.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: animetorrentsro -name: AnimeTorrents.ro -description: "AnimeTorrents.ro (Anime Torrents Romania) is a ROMANIAN Private Torrent Tracker for ANIME / MANGA" -language: ro-RO -type: private -encoding: UTF-8 -links: - - https://animetorrents.io/ -legacylinks: - - https://animetorrents.ro/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "ANIME"} - - {id: 1, cat: Movies/Other, desc: "ANIME"} # for Radarr - - {id: 2, cat: Books/Comics, desc: "MANGA"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: cat-id - type: select - label: Category - default: 0 - options: - 0: All categories - 1: Anime - 2: Manga - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts under VIP and without parked are automatically deleted after about a year." - -login: - path: login.php - method: form - form: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - referer: / - selectorinputs: - csrf_token: - selector: input[name="csrf_token"] - attribute: value - error: - - selector: table.main:contains("esuat") - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://animetorrents.ro/browse.php?search=2023&incldead=1&blah=0&cat=0 - - path: browse.php - inputs: - search: "{{ .Keywords }}" - # 0 active, 1 all, 2 dead, 3 free - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 name, 1 genre - blah: 0 - # site does not support multiple cats - cat: "{{ .Config.cat-id }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not support imdbid search - - rows: - selector: td > table > tbody > tr:has(a[href^="download.php/"]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["^(\\[.+?\\]).(.+)", "$2.$1"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) width" - files: - selector: td:nth-last-child(8) - date: - selector: td:nth-last-child(6) - filters: - - name: append - args: " +00:00" # auto adjusted by site account profile - - name: dateparse - args: "dd-MM-yyyyHH:mm:ss zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - img[alt="Free"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - genre: - selector: a[href^="download.php/"] - description: - text: "{{ .Result.genre }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animeworld-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animeworld-api.yml deleted file mode 100644 index d0cc68c5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/animeworld-api.yml +++ /dev/null @@ -1,210 +0,0 @@ ---- -id: animeworld-api -name: AnimeWorld (API) -description: "AnimeWorld (AW) is a GERMAN Private Torrent Tracker for ANIME / MANGA / HENTAI" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://animeworld.cx/ - -caps: - categorymappings: - - {id: 1, cat: Movies/Other, desc: "Anime Filme"} - - {id: 2, cat: TV/Anime, desc: "Anime Serien"} - - {id: 3, cat: Audio, desc: "Anime Musik / OSTs"} - - {id: 4, cat: PC/Games, desc: "Anime Spiele"} - - {id: 5, cat: XXX, desc: "Anime Hentai"} - - {id: 6, cat: PC, desc: "Software"} - - {id: 7, cat: Other, desc: "Sonstiges"} - - {id: 8, cat: Movies, desc: "Filme"} - - {id: 9, cat: TV, desc: "Serien"} - - {id: 10, cat: PC/Games, desc: "Spiele"} - - {id: 11, cat: Audio, desc: "Musik"} - - {id: 12, cat: Books, desc: "Manga"} - - {id: 13, cat: Movies, desc: "Cartoon Filme"} - - {id: 14, cat: TV, desc: "Cartoon Serie"} - - {id: 15, cat: XXX, desc: "H-Manga / Doujinshi"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your AnimeWorld account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: tmdbidonly - type: checkbox - label: Disable IMDB and TVDB ID search (only support TMDB ID) to potentially improve Sonarr and Radarr results - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts are set as inactive by the system after 180 days. After 270 days in inactive status, the accounts are automatically deleted from the system." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - imdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.IMDBIDShort }}{{ end }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.TVDBID }}{{ end }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - 0: "{{ .False }}" - 1: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 1.0 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 8.1.3 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/anisource.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/anisource.yml deleted file mode 100644 index 25043c89..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/anisource.yml +++ /dev/null @@ -1,87 +0,0 @@ ---- -id: anisource -name: AniSource -description: "AniSource is a Public site for HD Anime raws." -language: en-US -type: public -encoding: UTF-8 -requestDelay: 5 -links: - - https://asnet.pw/ - -settings: [] - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Raw Animes"} - - modes: - search: [q] - tv-search: [q, season, ep] - -search: - paths: - - path: / - - path: / - inputs: - tpage: 2 - inputs: - search: "{{ .Keywords }}" - - headers: - # site blocks Jackett's User-Agents, so slightly alter it here (e.g. Chrome/87.0. > Chrome/97.0.) - User-Agent: ["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4280.88 Safari/537.36"] - - rows: - selector: div[class^="item"] - - fields: - category: - text: 1 - title: - selector: a[href*="/showprofile/"] - filters: - # move group to the end - - name: re_replace - args: ["^(\\[.+?\\]) (.+?)$", "$2 $1"] - details: - selector: a[href*="/showprofile/"] - attribute: href - infohash: - selector: a[href*="/showprofile/"] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - date: - selector: font[color="Black"] - filters: - - name: regexp - args: "Date: (.+?) Central" - - name: replace - args: [" at ", " "] - - name: append - args: " -09:00" # CUS - - name: dateparse - args: "yyyy-MM-dd HH:mm zzz" - size: - selector: font[color="Black"] - filters: - - name: regexp - args: "Size: (.+?) \\|" - - name: replace - args: ["b", "B"] - files: - selector: font[color="Black"] - filters: - - name: regexp - args: (\d+)$ - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/anthelion-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/anthelion-api.yml deleted file mode 100644 index 616d605f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/anthelion-api.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: anthelion-api -replaces: - - tehconnectionme - - anthelion -name: Anthelion (API) -description: "Anthelion (ANT) is a Private Torrent Tracker for MOVIES" -language: en-US -type: private -encoding: UTF-8 -# Status: https://ant.trackerstatus.info/ -links: - - https://anthelion.me/ - -caps: - categorymappings: - - {id: 2000, cat: Movies} - - {id: 2030, cat: Movies/SD} - - {id: 2040, cat: Movies/HD} - - {id: 2045, cat: Movies/UHD} - - {id: 2050, cat: Movies/BluRay} - - {id: 2080, cat: Movies/WEB-DL} - - {id: 8000, cat: Other} - - modes: - search: [q] - movie-search: [q, imdbid, tmdbid] - # note: the api any supports movie search so there is no tv season/episode support. - # not using year search because torznab adds it to the q= which results in &q=keyword+2022&year=2022 sent to api !?! - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Key by accessing your Anthelion account Settings page and scrolling down to the API Key section.
Tick the Search and Download checkboxes and click the save profile button to generate the key." - - name: info_activity - type: info - label: Account Inactivity - default: "Activity is defined as actually using your account, including the snatch/grab/seeding of torrents. You are also required to sign-in to the web frontend of Anthelion at least once per 3mo (90 days) (Note, using autodl/ARR does not satisfy this)." - -login: - path: api.php - method: get - inputs: - api_key: "{{ .Config.apikey }}" - error: - - selector: ":root:contains(\"Unrecognized key\")" - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: ":root:contains(\"No API_KEY provided\")" - message: - text: "You did not enter an API key." - -search: - paths: - # docs: https://anthelion.me/forums.php?action=viewthread&threadid=1026 - - path: api.php - response: - type: json - - inputs: - api_key: "{{ .Config.apikey }}" - o: json - t: movie - q: "{{ .Keywords }}" - tmdb: "{{ .Query.TMDBID }}" - imdb: "{{ .Query.IMDBIDShort }}" - # category filtering is disabled since it seems to rely on the AND operator that prevents some releases to show on search. - # cat: "{{ join .Categories \",\" }}" - limit: 100 - offset: 0 - - rows: - selector: item - count: - selector: response.total - - fields: - category: - selector: category - filters: - - name: split - args: [",", -1] - - name: re_replace - args: ["1\\d{5}", "2000"] - year: - selector: year - optional: true - _codec: - selector: codec - _container: - selector: container - filters: - - name: toupper - _media: - selector: media - filters: - - name: replace - args: ["WEB", "WEB-DL"] - _resolution: - selector: resolution - _audioFormat: - selector: audioFormat - _subbing: - selector: subbing - optional: true - _flags: - selector: flags - optional: true - filters: - - name: replace - args: [",", " "] - _language: - selector: language - optional: true - _releaseGroup: - selector: releaseGroup - optional: true - filters: - - name: trim - title_optional: - selector: title - filters: - - name: htmldecode - - name: append - args: " {{ .Result.year }} {{ .Result._resolution }} {{ .Result._media }} {{ .Result._container }} {{ .Result._codec }} {{ .Result._audioFormat }}{{ if .Result._subbing }} Subs{{ else }}{{ end }}{{ if .Result._flags }} {{ .Result._flags }}{{ else }}{{ end }}{{ if .Result._language }} {{ .Result._language }}{{ else }}{{ end }}{{ if and (.Result._releaseGroup) (ne .Result._releaseGroup \"NULL\") }}-{{ .Result._releaseGroup }}{{ else }}{{ end }}" - - name: replace - args: ["M2TS H265", "M2TS HEVC"] - - name: replace - args: ["M2TS H264", "M2TS AVC"] - details: - selector: guid - download: - selector: link - filters: - - name: htmldecode - infohash: - selector: infohash - imdbid: - selector: imdb - tmdbid: - selector: tmdb - genre: - selector: tags - filters: - - name: replace - args: [".", "_"] - description: - text: "{{ .Result.genre }}" - files: - selector: fileCount - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: grabs - date: - # 2022-08-07T20:56:06+0200 - selector: pubDate - size: - selector: size - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 day (as seconds = 3 x 24 x 60 x 60) - text: 259200 - _filename: - selector: "files[0].name" - filters: - - name: htmldecode - optional: true - title: - text: "{{ if and (eq .Result.files \"1\") (.Result._filename) }}{{ .Result._filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabafenice.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabafenice.yml deleted file mode 100644 index 14f025c8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabafenice.yml +++ /dev/null @@ -1,235 +0,0 @@ ---- -id: arabafenice -name: ArabaFenice -description: "Araba Fenice (Phoenix) is an ITALIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: private -encoding: UTF-8 -links: - - https://www.arabafenice.me/ -legacylinks: - - http://www.arabafenice.me/ - -caps: - categorymappings: - # HRS - - {id: 34, cat: Movies/HD, desc: "1080p HRS x264"} - - {id: 47, cat: Movies/UHD, desc: "2160p 4K UHD HRS"} - - {id: 35, cat: TV, desc: "Serie TV HRS"} - - {id: 36, cat: Movies/SD, desc: "DVDRip HRS"} - - {id: 41, cat: Movies/SD, desc: "BDRip 576p HRS"} - - {id: 39, cat: Movies/HD, desc: "1080p HRS x265 HEVC"} - # AREA SuperVip - - {id: 33, cat: Movies/HD, desc: "[DD] Film 720p MP4"} - - {id: 54, cat: Movies/HD, desc: "[DD] 1080p V.U. MKV"} - - {id: 55, cat: Movies/BluRay, desc: "[DD] FULL BluRay"} - - {id: 57, cat: Movies/UHD, desc: "[DD] 4K FULL BluRay"} - - {id: 58, cat: XXX, desc: "[DD] XXX 1080p"} - - {id: 59, cat: XXX, desc: "[DD] XXX 4K 2160p"} - - {id: 60, cat: XXX, desc: "[DD] XXX 720p"} - # VIDEO - - {id: 1, cat: Movies, desc: "News Cinema"} - - {id: 40, cat: TV/UHD, desc: "4K Video Untouched"} - - {id: 2, cat: Movies/SD, desc: "BD-DVDRip"} - - {id: 3, cat: Movies/DVD, desc: "DVD 5"} - - {id: 50, cat: Movies/UHD, desc: "4K 2160p UHD"} - - {id: 6, cat: Movies/BluRay, desc: "BluRay Full"} - - {id: 4, cat: Movies/HD, desc: "1080p 3D x264"} - - {id: 7, cat: Movies/HD, desc: "1080p x264"} - - {id: 46, cat: Movies/HD, desc: "1080p VideoUntouched"} - - {id: 44, cat: Movies/HD, desc: "1080p x265 HEVC"} - - {id: 5, cat: Movies/DVD, desc: "DVD 9"} - - {id: 9, cat: TV/Anime, desc: "Cartoons"} - - {id: 8, cat: TV/Anime, desc: "720p x264"} - - {id: 12, cat: TV, desc: "Stagioni Concluse"} - - {id: 13, cat: TV, desc: "Stagioni in Onda"} - - {id: 14, cat: TV, desc: "TV Show"} - - {id: 42, cat: TV, desc: "Serie Tv Sub Ita"} - - {id: 15, cat: TV/Documentary, desc: "Documentari"} - - {id: 53, cat: TV/HD, desc: "VIDEO MP4 720p"} - - {id: 38, cat: XXX, desc: "xXx"} - - {id: 43, cat: Other, desc: "Araba per il sociale"} - # MUSICA - - {id: 17, cat: Audio, desc: "Musica Italiana"} - - {id: 45, cat: Audio, desc: "Discografie"} - - {id: 18, cat: Audio, desc: "MusicaInternazionale"} - - {id: 19, cat: Audio, desc: "Compilation"} - # PDF - - {id: 21, cat: Books, desc: "Ebook"} - - {id: 22, cat: Books/Comics, desc: "Comics"} - - {id: 23, cat: Books, desc: "Newsstand"} - - {id: 56, cat: Books/Mags, desc: "Riviste"} - - {id: 48, cat: XXX/Other, desc: "Riviste xXx"} - - {id: 49, cat: XXX/Other, desc: "Fumetti xXx"} - # GAMES - - {id: 25, cat: Console/PS4, desc: "Sony Games"} - - {id: 26, cat: Console/XBox, desc: "XboX Games"} - - {id: 27, cat: Console/Other, desc: "Nintendo Games"} - - {id: 28, cat: PC/Games, desc: "PC Games"} - # SOFTWARE - - {id: 30, cat: PC/ISO, desc: "Windows APP"} - - {id: 31, cat: PC/Mobile-iOS, desc: "Apple APP"} - - {id: 32, cat: PC/Mobile-Android, desc: "Android APP"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: body[onLoad^="makeAlert('"] - message: - selector: body[onLoad^="makeAlert('"] - attribute: onLoad - filters: - - name: replace - args: ["makeAlert('Error' , '", ""] - - name: replace - args: ["');", ""] - test: - path: index.php - selector: a[href="logout.php"] - -download: - before: - path: thanks.php - method: post - inputs: - infohash: "{{ .DownloadUri.Query.id }}" - thanks: 1 - rndval: "1487013827343" - selectors: - - selector: a[href^="download.php?id="] - attribute: href - filters: - - name: regexp - args: "^(.+?)&key=" - -search: - paths: - - path: index.php - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to 1 - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace # S01E01 to 1 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1 $2"] - inputs: - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBIDShort }}{{ else }}{{ .Keywords }}{{ end }}" - page: torrents - category: "{{ range .Categories }}{{.}};{{end}}" - options: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table > tbody > tr > td > table.lista > tbody > tr:has(a[href^="index.php?page=torrent-details&id="]) - - fields: - download: - selector: a[href^="index.php?page=downloadcheck&id="] - attribute: href - title: - selector: a[onmouseover][href^="index.php?page=torrent-details&id="] - filters: - - name: re_replace # S01 E01 to S01E01 - args: ["(?i)\\bS(\\d+)\\sE(\\d+)\\b", "S$1E$2"] - - name: re_replace # 01x01 to S01E01 - args: ["(?i)(\\d{2})x(\\d+)", "S$1E$2"] - - name: re_replace # 1x01 to S01E01 - args: ["(?i)\\b(\\d{1})x(\\d+)", "S0$1E$2"] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - - name: re_replace # Episodio 4 to E4 - args: ["(?i)\\b(?:[\\/\\|]?Episodio\\s?(\\d+)|Puntata\\s?(\\d+))", "E$1$2"] - - name: re_replace # Episodi 4 5 to E04-05 - args: ["(?i)\\b(?:Puntate\\s*)(\\d+)\\s?(\\d+)", "E0$1-0$2"] - poster: - selector: a[onmouseover][href^="index.php?page=torrent-details&id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.*?) " - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - details: - selector: a[onmouseover][href^="index.php?page=torrent-details&id="] - attribute: href - size: - selector: td:nth-last-child(4) - date: - selector: td:nth-last-child(9) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd/MM/yyyy zzz" - grabs: - selector: td:nth-last-child(6) - seeders: - selector: td:nth-last-child(8) - leechers: - selector: td:nth-last-child(7) - downloadvolumefactor: - case: - img[alt="Gold 100% Free"]: 0 - img[alt="Silver 50% Free"]: 0.5 - img[alt="Bronze 25% Free"]: 0.75 - "*": 1 - uploadvolumefactor: - case: - img[alt="2x Upload Multiplier"]: 2 - img[alt="3x Upload Multiplier"]: 3 - img[alt="4x Upload Multiplier"]: 4 - img[alt="5x Upload Multiplier"]: 5 - img[alt="6x Upload Multiplier"]: 6 - img[alt="7x Upload Multiplier"]: 7 - img[alt="8x Upload Multiplier"]: 8 - img[alt="9x Upload Multiplier"]: 9 - img[alt="10x Upload Multiplier"]: 10 - "*": 1 -# xbtitFM v1.16 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabicsource-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabicsource-api.yml deleted file mode 100644 index a97d0516..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabicsource-api.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: arabicsource-api -name: ArabicSource (API) -description: "ArabicSource is an ARABIC Private Torrent Tracker for MOVIES / TV" -language: ar-SA -type: private -encoding: UTF-8 -links: - - https://arabicsource.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "أفلام رسوم مدبلجة (Dubbed Animated Films)"} - - {id: 2, cat: Movies, desc: "أفلام الرسوم المدبلجة (Subtitled Animated Films)"} - - {id: 3, cat: Movies, desc: "الأفلام العربية (Arabic Films)"} - - {id: 4, cat: Movies, desc: "أفلام أجنبية (Foreign Films)"} - - {id: 5, cat: TV, desc: "مسلسلات رسوم مدبلجة (Dubbed Animated Series)"} - - {id: 6, cat: TV, desc: "مسلسلات رسوم مترجمة (Subtitled Animated Series)"} - - {id: 7, cat: TV, desc: "مسلسلات عربية (Arabic Series)"} - - {id: 8, cat: TV, desc: "مسرحيات (Plays)"} - - {id: 10, cat: Other, desc: "إسلاميات (Islamic Content)"} - - {id: 11, cat: Other, desc: "رمضانيات (Ramadan Content)"} - - {id: 12, cat: Other, desc: "منوعات (Variety)"} - - {id: 13, cat: Audio, desc: "صوتيات (Audio)"} - - {id: 14, cat: Movies, desc: "كرتون كلاسيك (Classic Cartoons)"} - - {id: 15, cat: Other, desc: "تورنت خام (Raw Torrents)"} - - {id: 20, cat: TV/Documentary, desc: "مسلسلات وثائقيات (Documentary Series)"} - - {id: 9, cat: Movies, desc: "أفلام وثائقيات (Documentary Films)"} - - {id: 19, cat: Other, desc: "تورينتات ضائعة (Lost Torrents)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your ArabicSource account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "The maximum number of days you can stay away from the site is 40 days, and only if you suspend the account, you will get a grace period of 180 days, but you must contact the administration in advance so that this is added to your personal account and you are not exposed to expulsion." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 1.05 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabp2p.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabp2p.yml deleted file mode 100644 index 11d327a9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabp2p.yml +++ /dev/null @@ -1,213 +0,0 @@ ---- -id: arabp2p -name: ArabP2P -description: "ArabP2P is an ARABIC Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ar-AE -type: private -encoding: UTF-8 -links: - - https://www.arabp2p.net/ -legacylinks: - - http://www.arabp2p.com/ - - https://www.arabp2p.com/ - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in periodically. If the member is absent from the website for a period of 3 months, the account will be deleted without prior warning." - -caps: - categorymappings: - # المرئيات العربية Arabic Movies - - {id: 117, cat: TV, desc: "ارمضان 2026 (Ramadan)"} - - {id: 14, cat: Movies, desc: "اسلامي (Islamic)"} - - {id: 19, cat: TV/Documentary, desc: "وثائقي (Documentary)"} - - {id: 70, cat: TV, desc: "تعليمي (Educational)"} - - {id: 41, cat: Movies, desc: "افلام عربيه (Arabic Movies)"} - - {id: 88, cat: Movies, desc: "افلام مدبلجه عربي (Arabic Dubbed Movies)"} - - {id: 44, cat: TV, desc: "مسلسلات عربية (Arabic Series)"} - - {id: 89, cat: TV, desc: "مسلسلات عربية كامله (Full Arabic Series)"} - - {id: 52, cat: TV, desc: "مسرحيات (Plays)"} - - {id: 71, cat: TV, desc: "مسلسلات مدبلجه عربي (Arabic Dubbed Series)"} - - {id: 90, cat: TV, desc: "برامج ومسابقات (Shows)"} - # المرئيات الاجنبية Foreign Movies - - {id: 92, cat: TV/Foreign, desc: "تعليمي (Educational)"} - - {id: 93, cat: TV/Documentary, desc: "وثائقي (Documentary)"} - - {id: 45, cat: TV/Foreign, desc: "مسلسلات وبرامج اجنبيه (Serials)"} - - {id: 57, cat: TV/Foreign, desc: "مسلسلات آسيوية (Asian Series)"} - - {id: 42, cat: Movies/Foreign, desc: "افلام اجنبيه (Foreign)"} - - {id: 74, cat: Movies/HD, desc: "جودة عالية HD"} - - {id: 113, cat: TV/Foreign, desc: "مسلسلات لاتينية مترجم.مدبلج(Latin Series"} - - {id: 59, cat: Movies/Foreign, desc: "افلام آسيوية (Asian Movies)"} - - {id: 86, cat: Movies/Foreign, desc: "افلام هنديه (Indian Movies)"} - - {id: 114, cat: Movies/Foreign, desc: "افلام لاتينية مترجم.مدبلج (Latin Movies)"} - - {id: 115, cat: TV/Foreign, desc: "مسلسلات تركية مترجم.مدبلج (Turkish Series)"} - - {id: 116, cat: Movies/Foreign, desc: "افلام تركية مترجم.مدبلج (Turkish Movies)"} - # انمي مدبلج عربي Anime Arabic dubbed - - {id: 98, cat: TV/Anime, desc: "افلام (Movies)"} - - {id: 100, cat: TV/Anime, desc: "مسلسلات (Series)"} - - {id: 102, cat: TV/Anime, desc: "حلقات (Episdoes)"} - # انمي مترجم عربي Anime Arabic translator - - {id: 99, cat: TV/Anime, desc: "افلام (Movies)"} - - {id: 101, cat: TV/Anime, desc: "مسلسلات (Series)"} - - {id: 103, cat: TV/Anime, desc: "حلقات (Episodes)"} - - {id: 85, cat: TV/Anime, desc: "الكارتون الصامت والكلاسيكي (Cartoons)"} - # صوتيات Audio - - {id: 25, cat: Audio, desc: "القران الكريم (The Holy Quran)"} - - {id: 27, cat: Audio, desc: "محاضرات (Lectures)"} - - {id: 26, cat: Audio, desc: "اناشيد (Chants)"} - - {id: 118, cat: Audio, desc: "برامج صوتية (Programs)"} - # برامج Software - - {id: 22, cat: PC, desc: "برامج عربية (Arabic Software)"} - - {id: 23, cat: PC, desc: "برامج عامه (Public Software)"} - # اخرى Other - - {id: 17, cat: Books, desc: "كتب (Books)"} - - {id: 65, cat: Other, desc: "صور (Images)"} - - {id: 56, cat: Other, desc: "رياضي (Sport)"} - - {id: 46, cat: Other, desc: "منوع (Misc)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -login: - path: index.php - method: form - form: form[action^="index.php?page=login"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: tr td span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href^="logout.php?t="] - -search: - paths: - # https://www.arabp2p.net/index.php?page=torrents&search=&category=0&active=0&internel=0&order=3&by=2 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - # does not support multi categories, default to All - category: 0 - # 0 all 1 activeonly 2 deadonly - active: 0 - # 0 all 1 internal 2 external - internel: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - # does not support imdbid search or provide imdb link in results - keywordsfilters: - # Keep SxxEyy format for tv-search (Sonarr/Radarr). ArabP2P search does not reliably match - # when we convert to the Arabic bracket format unless the Arabic title is also present. - - name: re_replace # normalize "1x09" => "S1E09" (then pad below) - args: ["(?i)\\b(\\d{1,2})x(\\d{1,3})\\b", "S$1E$2"] - - name: re_replace # normalize "S01 E09" => "S01E09" - args: ["(?i)\\bS(\\d{1,2})\\s+E(\\d{1,3})\\b", "S$1E$2"] - - name: re_replace # pad season: S1E09 => S01E09 - args: ["(?i)\\bS(\\d)(?=E)", "S0$1"] - - name: re_replace # pad episode: S01E9 => S01E09 - args: ["(?i)E(\\d)\\b", "E0$1"] - - name: re_replace - args: ["\\s{2,}", " "] - - name: trim - - rows: - selector: table#torrents_list_p > tbody > tr:has(a[href^="download.php?id="]), table#torrents_list_p > tbody > tr:has(a[href^="magnet:?xt="]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="index.php?page=torrents&category="]:last-child - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details"] - filters: - - name: re_replace - args: ["(?<=\\d)\\s+(?=\\d)", " "] - - name: re_replace - args: ["\\[(\\d+(?:[\\s-]+\\d+)*)\\]\\s*\\[(\\d{4})\\]\\s*\\[م(\\d+)\\]", "S$3E$1 [$2]"] - - name: re_replace - args: ["\\[(\\d+(?:[\\s-]+\\d+)*)\\]\\s*\\[م(\\d+)\\]", "S$2E$1"] - - name: re_replace - args: ["^\\[(\\d+(?:[\\s-]+\\d+)*)\\](?!\\s*\\[م)", "S01E$1"] - - name: re_replace - args: ["E(\\d+)[\\s-]+(\\d+)", "E$1-E$2"] - - name: re_replace - args: ["\\[\\s*\\]", ""] - - name: re_replace - args: ["(?<=\\S)(\\[)", " $1"] - - name: re_replace - args: ["S(\\d)(?=E)", "S0$1"] - - name: re_replace - args: ["E(\\d)(?=\\D)", "E0$1"] - - name: re_replace - args: ["\\s{2,}", " "] - - name: trim - details: - selector: a[href^="index.php?page=torrent-details"] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - optional: true - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - optional: true - date: - selector: span.upload-date > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MM-yy-dd HH:mm:ss tt" - size: - selector: span.size - seeders: - selector: span[title="Seeders"] - leechers: - selector: span[title="Leechers"] - downloadvolumefactor: - case: - span.free: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.8 - minimumseedtime: - text: 259200 # 3 days -# xbtit diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabscene.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabscene.yml deleted file mode 100644 index 94e82a35..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabscene.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -id: arabscene -name: ArabScene -description: "ArabScene is an ARABIC Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ar-AE -type: private -encoding: UTF-8 -links: - - https://arabscene.me/ - -caps: - categorymappings: - - {id: 103, cat: Other, desc: "منوعات"} - - {id: 105, cat: Other, desc: " |-- مسرحيات"} - - {id: 104, cat: TV/Other, desc: " |-- اسلاميات "} - - {id: 120, cat: TV/Other, desc: " |-- توك شــو"} - - {id: 106, cat: PC/0day, desc: " |-- دورات تعليمية "} - - {id: 117, cat: TV/Foreign, desc: "مسلسلات مترجمة "} - - {id: 122, cat: TV/Foreign, desc: " |-- beIN "} - - {id: 123, cat: TV/Foreign, desc: " |-- OSN"} - - {id: 128, cat: TV/Foreign, desc: " |-- اجنبى"} - - {id: 160, cat: TV/Foreign, desc: " |-- تركى"} - - {id: 174, cat: TV/Foreign, desc: " |-- حلقات منفردة"} - - {id: 133, cat: TV/Foreign, desc: "مسلسلات مدبلجة"} - - {id: 136, cat: TV/Foreign, desc: " |-- هندى"} - - {id: 140, cat: TV/Foreign, desc: " |-- اجنبى"} - - {id: 173, cat: TV/Foreign, desc: " |-- تركى"} - - {id: 175, cat: TV/Foreign, desc: " |-- حلقات منفردة"} - - {id: 56, cat: TV, desc: "VIP"} - - {id: 81, cat: TV, desc: " |-- Full-Pack"} - - {id: 53, cat: TV/Foreign, desc: " |-- مسلسلات مدبلجة "} - - {id: 55, cat: TV, desc: " |-- مسلسلات عربية "} - - {id: 57, cat: Movies, desc: " |-- افلام عربية "} - - {id: 54, cat: TV/Anime, desc: " |-- الكرتون والانمى "} - - {id: 58, cat: TV/WEB-DL, desc: "Web-DL"} - - {id: 65, cat: Movies/WEB-DL, desc: " |-- Arabic Movies"} - - {id: 66, cat: TV/WEB-DL, desc: " |-- Arabic Series"} - - {id: 67, cat: TV/WEB-DL, desc: " |-- Dubbed Series"} - - {id: 73, cat: TV/WEB-DL, desc: " |-- Full-Pack "} - - {id: 113, cat: Movies/Foreign, desc: "افلام مترجمة "} - - {id: 121, cat: Movies/Foreign, desc: " |-- beIN"} - - {id: 125, cat: Movies/Foreign, desc: " |-- OSN"} - - {id: 115, cat: Movies/Foreign, desc: " |-- هندى "} - - {id: 114, cat: Movies/Foreign, desc: " |-- اجنبى"} - - {id: 161, cat: Movies/Foreign, desc: " |-- تركى"} - - {id: 143, cat: Movies/Foreign, desc: "افلام مدبلجة"} - - {id: 149, cat: Movies/Foreign, desc: " |-- beIN"} - - {id: 148, cat: Movies/Foreign, desc: " |-- OSN"} - - {id: 145, cat: Movies/Foreign, desc: " |-- هندى"} - - {id: 146, cat: Movies/Foreign, desc: " |-- اجنبى"} - - {id: 144, cat: Movies/Foreign, desc: " |-- تركى"} - - {id: 109, cat: Audio, desc: "الموسيقى العربية "} - - {id: 110, cat: Audio/Lossless, desc: " |-- FLAC"} - - {id: 111, cat: Audio/MP3, desc: " |-- Mp3"} - - {id: 112, cat: Audio, desc: " |-- كليبات"} - - {id: 99, cat: TV, desc: "المسلسلات العربية"} - - {id: 100, cat: TV, desc: " |-- مصرية"} - - {id: 139, cat: TV, desc: " |-- مغربية"} - - {id: 138, cat: TV, desc: " |-- تونسية"} - - {id: 172, cat: TV, desc: " |-- حلقات منفردة"} - - {id: 101, cat: TV, desc: " |-- خليجية"} - - {id: 102, cat: TV, desc: " |-- سورية"} - - {id: 84, cat: TV/Documentary, desc: "الوثائقيات "} - - {id: 85, cat: TV/Documentary, desc: " |-- مترجمة"} - - {id: 87, cat: TV/Documentary, desc: " |-- عربية "} - - {id: 89, cat: Movies, desc: "الافلام العربية"} - - {id: 95, cat: Movies, desc: " |-- 2000s"} - - {id: 137, cat: Movies, desc: " |-- 2010s"} - - {id: 49, cat: Movies, desc: " |-- 2020s"} - - {id: 98, cat: Movies, desc: " |-- 40s"} - - {id: 90, cat: Movies, desc: " |-- 50s"} - - {id: 91, cat: Movies, desc: " |-- 60s"} - - {id: 92, cat: Movies, desc: " |-- 70s"} - - {id: 93, cat: Movies, desc: " |-- 80s"} - - {id: 94, cat: Movies, desc: " |-- 90s"} - - {id: 4, cat: Movies/Other, desc: "الرسوم المتحركة"} - - {id: 5, cat: Movies/Other, desc: " |-- مترجم"} - - {id: 6, cat: Movies/Other, desc: " |-- مدبلج"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "All inactive accounts will be deleted after 60 days of inactivity." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table:has(td.thead:contains("error")) - test: - path: index.php - selector: a[href*="logout.php"] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ .DownloadUri.Query.id }}" - -search: - paths: - - path: browse.php - - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_both - # does not support multi category searching so defaulting to all. - category: 0 - include_dead_torrents: yes - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - rows: - selector: table#sortabletable > tbody > tr:has(span > a[href*="details.php?id="]) - - fields: - category: - selector: a[href*="?category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href*="/details.php?id="] - filters: - - name: re_replace - args: ["(?<=\\d)\\s+(?=\\d)", " "] - - name: re_replace - args: ["\\[(\\d+(?:[\\s-]+\\d+)*)\\]\\s*\\[(\\d{4})\\]\\s*\\[م(\\d+)\\]", "S$3E$1 [$2]"] - - name: re_replace - args: ["\\[(\\d+(?:[\\s-]+\\d+)*)\\]\\s*\\[م(\\d+)\\]", "S$2E$1"] - - name: re_replace - args: ["^\\[(\\d+(?:[\\s-]+\\d+)*)\\](?!\\s*\\[م)", "S01E$1"] - - name: re_replace - args: ["E(\\d+)[\\s-]+(\\d+)", "E$1-E$2"] - - name: re_replace - args: ["\\[\\s*\\]", ""] - - name: re_replace - args: ["(?<=\\S)(\\[)", " $1"] - - name: re_replace - args: ["S(\\d)(?=E)", "S0$1"] - - name: re_replace - args: ["E(\\d)(?=\\D)", "E0$1"] - - name: re_replace - args: ["\\s{2,}", " "] - - name: trim - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/download.php?id="] - attribute: href - date: - selector: td:nth-child(2) > div:nth-child(3) - filters: - - name: regexp - args: "(\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2})" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - poster: - selector: div.tooltip-content > img[src*="/torrents/"] - attribute: src - downloadvolumefactor: - case: - img[src$="torrent_flags/freedownload.gif"]: 0 - img[src$="torrent_flags/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="torrent_flags/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# TS Special Edition v.7.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabtorrents-com.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabtorrents-com.yml deleted file mode 100644 index e05db26a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabtorrents-com.yml +++ /dev/null @@ -1,136 +0,0 @@ ---- -id: arabtorrents-com -name: arab-torrents.com -description: "arab-torrents.com is an ARABIC Public Torrent Tracker for MOVIES / TV / GENERAL" -language: ar-AE -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://arab-torrents.com/ - -caps: - categorymappings: - # categories containing '»' have a double space in search results - - {id: 14, cat: Other, desc: "اسلامي"} - - {id: 17, cat: Books, desc: "كتب"} - - {id: 19, cat: TV/Documentary, desc: "المرئيات العربية » وثائقي"} - - {id: 22, cat: PC, desc: "برامج عربية"} - - {id: 23, cat: PC, desc: "برامج عامه"} - - {id: 25, cat: Other, desc: "القران الكريم"} - - {id: 26, cat: Audio, desc: "اناشيد"} - - {id: 27, cat: Other, desc: "محاضرات"} - - {id: 41, cat: Movies, desc: "افلام عربيه"} - - {id: 44, cat: TV, desc: "مسلسلات عربية"} - - {id: 46, cat: Audio, desc: "منوع"} - - {id: 52, cat: Movies/Other, desc: "مسرحيات"} - - {id: 56, cat: TV/Sport, desc: "رياضي"} - - {id: 57, cat: TV/Foreign, desc: "مسلسلات آسيوية"} - - {id: 59, cat: Movies/Foreign, desc: "افلام آسيوية"} - - {id: 65, cat: Other, desc: "صور"} - - {id: 70, cat: Other, desc: "المرئيات العربية » تعليمي"} - - {id: 71, cat: TV/Foreign, desc: "مسلسلات مدبلجه عربي"} - - {id: 86, cat: Movies/Foreign, desc: "افلام هنديه"} - - {id: 88, cat: Movies/Foreign, desc: "افلام مدبلجه عربي"} - - {id: 89, cat: TV, desc: "مسلسلات عربية كامله"} - - {id: 90, cat: TV, desc: "برامج ومسابقات"} - - {id: 92, cat: Other, desc: "المرئيات الاجنبية » تعليمي"} - - {id: 93, cat: TV/Documentary, desc: "المرئيات الاجنبية » وثائقي"} - - {id: 98, cat: Movies/Other, desc: "انمي مدبلج عربي » افلام"} - - {id: 99, cat: Movies/Other, desc: "انمي مترجم عربي » افلام"} - - {id: 100, cat: TV/Anime, desc: "انمي مدبلج عربي » مسلسلات"} - - {id: 101, cat: TV/Anime, desc: "انمي مترجم عربي » مسلسلات"} - - {id: 102, cat: TV/Anime, desc: "انمي مدبلج عربي » حلقات"} - - {id: 103, cat: TV/Anime, desc: "انمي مترجم عربي » حلقات"} - - {id: 113, cat: TV/Foreign, desc: "مسلسلات لاتينية مترجم.مدبلج"} - - {id: 114, cat: Movies/Foreign, desc: "افلام لاتينية مترجم.مدبلج"} - - {id: 115, cat: TV/Foreign, desc: "مسلسلات تركية مترجم.مدبلج"} - - {id: 116, cat: Movies/Foreign, desc: "افلام تركية مترجم.مدبلج"} - - {id: 117, cat: TV, desc: "رمضان 2025"} - # categories not listed - - {id: missing1, cat: TV/Other, desc: "الكارتون الصامت والكلاسيكي"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -search: - paths: - - path: index.php - - path: index.php - inputs: - p: 2 - - inputs: - page: torrents - search: "{{ .Keywords }}" - # does not support multi-category search - - keywordsfilters: - - name: re_replace - args: ["^.{1,2}$", ""] - - rows: - selector: table#torrents > tbody > tr - - fields: - categorydesc: - selector: div.fcat - title: - selector: a[href^="/index.php?page=torrents&tid="] img - attribute: alt - filters: - - name: re_replace - args: ["(?<=\\d)\\s+(?=\\d)", " "] - - name: re_replace - args: ["\\[(\\d+(?:[\\s-]+\\d+)*)\\]\\s*\\[(\\d{4})\\]\\s*\\[م(\\d+)\\]", "S$3E$1 [$2]"] - - name: re_replace - args: ["\\[(\\d+(?:[\\s-]+\\d+)*)\\]\\s*\\[م(\\d+)\\]", "S$2E$1"] - - name: re_replace - args: ["^\\[(\\d+(?:[\\s-]+\\d+)*)\\](?!\\s*\\[م)", "S01E$1"] - - name: re_replace - args: ["E(\\d+)[\\s-]+(\\d+)", "E$1-E$2"] - - name: re_replace - args: ["\\[\\s*\\]", ""] - - name: re_replace - args: ["(?<=\\S)(\\[)", " $1"] - - name: re_replace - args: ["S(\\d)(?=E)", "S0$1"] - - name: re_replace - args: ["E(\\d)(?=\\D)", "E0$1"] - - name: re_replace - args: ["\\s{2,}", " "] - - name: trim - details: - selector: a[href^="/index.php?page=torrents&tid="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - poster: - selector: a:has(img.posterIcon) - attribute: href - tmdbid: - selector: a[href*="themoviedb.org/movie/"], a[href*="themoviedb.org/tv/"] - attribute: href - date: - text: now - size: - selector: div.fsize - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: td - remove: a[href^="magnet:?xt="], div -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabtorrents.yml deleted file mode 100644 index f8813d23..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/arabtorrents.yml +++ /dev/null @@ -1,221 +0,0 @@ ---- -id: arabtorrents -name: ArabTorrents -description: "ArabTorrents is an ARABIC Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ar-AE -type: private -encoding: windows-1256 -requestDelay: 2 -links: - - https://arab-torrents.net/ - -caps: - categorymappings: - # Movies - - {id: 36, cat: Movies, desc: "Movies/Dubbed"} - - {id: 134, cat: Movies, desc: "HD Section"} - - {id: 135, cat: Movies/HD, desc: "Movies/HD Section"} - - {id: 204, cat: Movies/HD, desc: "R5/HD Section"} - - {id: 119, cat: Movies/SD, desc: "Movies/SD TV"} - - {id: 205, cat: Movies/Foreign, desc: "Foreign"} - - {id: 208, cat: Movies/Foreign, desc: "Stranger/Foreign"} - - {id: 209, cat: Movies/Foreign, desc: "Turkish/Foreign"} - - {id: 207, cat: Movies/Foreign, desc: "Indian/Foreign"} - - {id: 191, cat: Movies, desc: "Movies/Golden-VIP"} - - {id: 190, cat: Movies, desc: "Movies/Super VIP"} - - {id: 212, cat: Movies, desc: "Movies/VIP"} - - {id: 181, cat: Movies, desc: "Classic Movies/WEB-DL"} - - {id: 167, cat: Movies, desc: "Movies/WEB-DL"} - # Shows - - {id: 186, cat: TV, desc: "Dubbed"} - - {id: 166, cat: TV/WEB-DL, desc: "WEB-DL"} - - {id: 168, cat: TV/WEB-DL, desc: "Series/WEB-DL"} - - {id: 171, cat: TV/WEB-DL, desc: "TV Shows/WEB-DL"} - - {id: 180, cat: TV/WEB-DL, desc: "Classic Series/WEB-DL"} - - {id: 169, cat: TV/WEB-DL, desc: "Full-Pack/WEB-DL"} - - {id: 210, cat: Other, desc: "VIP"} - - {id: 211, cat: TV, desc: "TV Shows/VIP"} - - {id: 213, cat: TV, desc: "Full-Pack/VIP"} - - {id: 94, cat: TV, desc: "Super VIP"} - - {id: 189, cat: TV, desc: "TV Shows/Super VIP"} - - {id: 201, cat: TV, desc: "Full-Pack/Super VIP"} - - {id: 197, cat: TV, desc: "TV Shows/Ramadan 2022"} - - {id: 148, cat: TV/Other, desc: "Series/OSN"} - - {id: 149, cat: TV/Other, desc: "TV Show/OSN"} - - {id: 165, cat: TV, desc: "Golden-VIP"} - - {id: 185, cat: TV, desc: "Series/Golden-VIP"} - - {id: 200, cat: TV, desc: "Full-Pack/Golden-VIP"} - - {id: 26, cat: TV/SD, desc: "SD TV"} - - {id: 78, cat: TV/SD, desc: "Series/SD TV"} - - {id: 124, cat: TV/SD, desc: "FullPack/SD TV"} - - {id: 136, cat: TV/HD, desc: "Series/HD Section"} - - {id: 137, cat: TV/HD, desc: "TV Shows/HD Section"} - - {id: 182, cat: TV/HD, desc: "Full Pack/HD Section"} - - {id: 187, cat: TV/Foreign, desc: "TV Shows/Dubbed"} - - {id: 188, cat: TV/Foreign, desc: "Full-Pack/Dubbed"} - # Anime - - {id: 33, cat: TV/Anime, desc: "Anime"} - - {id: 51, cat: TV/Anime, desc: "Series/Anime"} - - {id: 129, cat: Movies/BluRay, desc: "Blu-Ray/Anime"} - - {id: 125, cat: TV, desc: "Full-Pack/Anime"} - - {id: 50, cat: Movies, desc: "Movies/Anime"} - # Islamic - - {id: 67, cat: Other, desc: "Islamic"} - - {id: 126, cat: TV, desc: "Full-Packs/Islamic"} - - {id: 91, cat: Audio/MP3, desc: "MP3/Islamic"} - - {id: 92, cat: Other, desc: "Video/Islamic"} - # Music - - {id: 16, cat: Audio, desc: "Music"} - - {id: 177, cat: Audio, desc: "Flac/Music"} - - {id: 127, cat: Audio, desc: "Full-Packs/Music"} - - {id: 17, cat: Audio/MP3, desc: "MP3/Music"} - - {id: 19, cat: Audio/Video, desc: "Videos/Music"} - # OSN - - {id: 146, cat: TV/Other, desc: "OSN"} - - {id: 147, cat: TV/Other, desc: "Arabic/OSN"} - - {id: 153, cat: TV/Other, desc: "English/OSN"} - - {id: 179, cat: TV/Other, desc: "Old/OSN"} - # Ramadan 2022 - - {id: 192, cat: Other, desc: "Ramadan 2022"} - - {id: 198, cat: Other, desc: "Full Pack/Ramadan 2022"} - - {id: 193, cat: Other, desc: "Ramadan HD/Ramadan 2022"} - - {id: 195, cat: Other, desc: "Ramadan Khaliji/Ramadan 2022"} - - {id: 194, cat: Other, desc: "Ramadan SD/Ramadan 2022"} - - {id: 196, cat: Other, desc: "Ramadan Web/Ramadan 2022"} - # Sports - - {id: 120, cat: TV/Sport, desc: "Sports"} - - {id: 159, cat: TV/Sport, desc: "Full Matches/Sports"} - - {id: 155, cat: TV/Sport, desc: "Goals/Sports"} - # Rest - - {id: 102, cat: TV/Other, desc: "Other/SD TV"} - - {id: 202, cat: Other, desc: "AT"} - - {id: 203, cat: Other, desc: "AT/AT"} - - {id: 34, cat: Other, desc: "Misc"} - - {id: 132, cat: PC/Mobile-Android, desc: "Android/Misc"} - - {id: 163, cat: TV/Documentary, desc: "Documentary/Misc"} - - {id: 122, cat: Other, desc: "Other/Misc"} - - {id: 121, cat: Other, desc: "Theater/Misc"} - - {id: 214, cat: Other, desc: "Other/VIP"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table:has(td.thead:contains("حدث خطأ")) - test: - path: index.php - selector: a[href*="logout.php"] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ re_replace .DownloadUri.PathAndQuery \".*download-torrent-(\\d+).*\" \"$1\"}}" - -search: - paths: - - path: browse.php - - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_both - # does not support multi category searching so defaulting to all. - category: 0 - include_dead_torrents: yes - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: ["\\W+", "*"] - - name: re_replace - args: ["^(.+)$", "*$1*"] - - rows: - selector: table#sortabletable > tbody > tr:has(span > a[href*="details.php?id="]) - - fields: - category: - selector: td a[href*="/torrent-category-"] - attribute: href - filters: - - name: regexp - args: "torrent-category-(\\d+)\/" - title: - selector: td div.tooltip-target - details: - selector: span a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/download-torrent-"] - attribute: href - date: - selector: td:nth-child(2) > div:nth-child(3) - filters: - - name: regexp - args: "(\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2})" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - poster: - selector: div.tooltip-content > img[src*="/torrents/"] - attribute: src - downloadvolumefactor: - case: - img[src$="torrent_flags/freedownload.gif"]: 0 - img[src$="torrent_flags/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="torrent_flags/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.05 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# TS Special Edition v.7.4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/asiancinema.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/asiancinema.yml deleted file mode 100644 index 5372caec..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/asiancinema.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -id: asiancinema -name: AsianCinema -description: "AsianCinema is a Private Tracker for ASIAN MOVIES / TV / MUSIC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://eiga.moi/ -legacylinks: - - https://asiancinema.me/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your AsianCinema account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your account will be automatically deleted from the database after 90 days of inactivity. If you return or visit the site with your account before that amount of time passes, then the inactivity timer will reset and your account will be fine for another 90 days.
Keep in mind that 'activity' is considered a visit to any page and that torrent traffic is not counted as account activity. In order to reset the inactivity timer all you have to do is visit a single page while logged in with your account. If your account is deleted for inactivity, please do not ask the site staff to un-delete it for you. The deletion is performed automatically by the server and, as such, your account cannot be recovered after it has been deleted." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/asiandvdclub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/asiandvdclub.yml deleted file mode 100644 index fc54fb40..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/asiandvdclub.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: asiandvdclub -name: AsianDVDClub -description: "AsianDVDClub (ADC) is a Private Torrent Tracker for ASIAN DVD and BluRay" -language: en-US -type: private -encoding: UTF-8 -links: - - https://asiandvdclub.org/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 3, cat: Movies, desc: "Japan"} - - {id: 5, cat: Movies, desc: "China"} - - {id: 4, cat: Movies, desc: "Korea"} - - {id: 2, cat: Movies, desc: "Hong Kong"} - - {id: 6, cat: Movies, desc: "Philippines"} - - {id: 7, cat: Movies, desc: "Taiwan"} - - {id: 8, cat: Movies, desc: "Thailand"} - - {id: 9, cat: Movies, desc: "Vietnam"} - - {id: 10, cat: Movies, desc: "Malaysia"} - - {id: 11, cat: Movies, desc: "Other Asian"} - - {id: 12, cat: Other, desc: "Miscellaneous"} - - {id: 15, cat: TV, desc: "TV Series"} - - {id: 16, cat: Audio, desc: "Music/OST"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - up: seeders - size: size - title: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "No special action required for red ribbons and permanent sponsors and above regardless of time, for others after 1 year of zero activity on tracker and/or website account will be closed." - -login: - path: login - method: post - inputs: - apple: evil - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: b:contains("Try again") - test: - path: index.php - selector: a[href="/logout"] - -search: - paths: - - path: torrents/ - inputs: - $raw: "{{ range .Categories }}countries[]={{.}}&{{end}}" - searchbox: "{{ .Keywords }}" - search: Search - golden: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - column: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - rows: - selector: table.torrenttable_helper > tbody > tr:has(img) - - fields: - category_optional: - selector: img[class^="cat-"] - attribute: class - optional: true - case: - img.cat-anime: 1 - img.cat-hk: 2 - img.cat-japan: 3 - img.cat-korea: 4 - img.cat-china: 5 - img.cat-phil: 6 - img.cat-taiwan: 7 - img.cat-thai: 8 - img.cat-viet: 9 - img.cat-malaysia: 10 - img.cat-rest: 11 - img.cat-india: 11 - img.cat-misc: 12 - img.cat-pinku: 12 - img.cat-drama: 15 - img.cat-ost: 16 - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}12{{ end }}" - title: - selector: span[id] - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="/torrent/"] - attribute: href - filters: - - name: replace - args: ["torrent", "download"] - poster: - selector: span[id] - attribute: data-ot - filters: - - name: regexp - args: src='(.+?)' - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - date: - selector: td:nth-child(9) span.small - filters: - - name: timeago - grabs: - selector: td:nth-child(10) - downloadvolumefactor: - case: - img[src$="images/golden.gif"]: 0 - img[src$="images/silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.5 - minimumseedtime: - # 45 days (as seconds = 45 x 24 x 60 x 60) - text: 3888000 -# Engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/audiences.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/audiences.yml deleted file mode 100644 index c098867d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/audiences.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -id: audiences -name: Audiences -description: "Audiences is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://audiences.me/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影/Movies"} - - {id: 402, cat: TV, desc: "剧集/TV-Series"} - - {id: 403, cat: TV, desc: "综艺/TV-Show"} - - {id: 406, cat: TV/Documentary, desc: "纪录片/Documentary"} - - {id: 408, cat: Audio, desc: "音乐/Music"} - - {id: 404, cat: Audio/Audiobook, desc: "有声书/Audiobook"} - - {id: 405, cat: Books, desc: "电子书/Ebook"} - - {id: 407, cat: TV/Sport, desc: "体育/Sports"} - - {id: 410, cat: PC/Games, desc: "游戏/Game"} - - {id: 411, cat: PC/0day, desc: "软件/Software"} - - {id: 410, cat: TV, desc: "学习/Study"} - - {id: 409, cat: Other, desc: "其他/Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Users with Extreme User and above will be retained forever
  2. Starting from 20230701, the non-login ban time will be adjusted to:
    • (Home Alone) User - 40 days
    • (Young and vigorous) Power User - 60 days
    • (Children of Jianghu) Elite User - 80 days
    • (Street Fighter) Crazy User - 100 days
    • (non-stop) Insane User - 120 days
    • (Outstanding Citizen) Veteran User - 180 days
  3. Users with no traffic (i.e. uploading/downloading data) If it is 0) If you do not log in for 7 consecutive days will be disabled
  4. 'Parked accounts' no longer enjoy the benefits of retaining accounts, and are also subject to the above rules.
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 justdead - incldead: 0 - # promotion: 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x 50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 2 title or imdb or douban url, 3 uploader, 4 imdb - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}2{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[title][href^="details.php?id="] - attribute: title - details: - selector: a[title][href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td:nth-child(2) - remove: a, img -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/audionews.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/audionews.yml deleted file mode 100644 index 67aa847e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/audionews.yml +++ /dev/null @@ -1,334 +0,0 @@ ---- -id: audionews -name: AudioNews -description: "AudioNews (AN) is a Private Torrent Tracker for AUDIO SOFTWARE / SAMPLES / ETC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://audionews.org/ - -caps: - categorymappings: - - {id: 106, cat: PC/Mobile-Other, desc: "Mobile Apps"} - - {id: 161, cat: PC/Mobile-iOS, desc: "Mobile Apps iOS"} - - {id: 160, cat: PC/Mobile-Android, desc: "Mobile Apps Android"} - - {id: 12, cat: PC, desc: "AUDIOAPPS"} - - {id: 32, cat: PC, desc: "AUDIOAPPS DAW Apps"} - - {id: 175, cat: PC, desc: "AUDIOAPPS Ableton"} - - {id: 177, cat: PC, desc: "AUDIOAPPS Cakewalk"} - - {id: 176, cat: PC, desc: "AUDIOAPPS REAPER"} - - {id: 174, cat: PC, desc: "AUDIOAPPS Steinberg"} - - {id: 34, cat: PC, desc: "AUDIOAPPS Mastering &Audio Editor Apps"} - - {id: 37, cat: PC, desc: "AUDIOAPPS DJ Apps"} - - {id: 13, cat: Audio, desc: "SAMPLE LIBRARIES"} - - {id: 62, cat: Audio, desc: "SAMPLE LIBRARIES AMG"} - - {id: 117, cat: Audio, desc: "SAMPLE LIBRARIES Bela D Media"} - - {id: 118, cat: Audio, desc: "SAMPLE LIBRARIES Beta Monkey Music"} - - {id: 51, cat: Audio, desc: "SAMPLE LIBRARIES Best Service"} - - {id: 46, cat: Audio, desc: "SAMPLE LIBRARIES Big Fish"} - - {id: 66, cat: Audio, desc: "SAMPLE LIBRARIES Bigga Giggas"} - - {id: 122, cat: Audio, desc: "SAMPLE LIBRARIES Bluezone Corporation"} - - {id: 123, cat: Audio, desc: "SAMPLE LIBRARIES Bunker 8"} - - {id: 126, cat: Audio, desc: "SAMPLE LIBRARIES Cinematique Instruments"} - - {id: 127, cat: Audio, desc: "SAMPLE LIBRARIES CineSamples"} - - {id: 128, cat: Audio, desc: "SAMPLE LIBRARIES ClusterSound"} - - {id: 121, cat: Audio, desc: "SAMPLE LIBRARIES Dan Dean"} - - {id: 195, cat: Audio, desc: "SAMPLE LIBRARIES Diginoiz"} - - {id: 52, cat: Audio, desc: "SAMPLE LIBRARIES Digital Juice"} - - {id: 134, cat: Audio, desc: "SAMPLE LIBRARIES Digital-Redux"} - - {id: 65, cat: Audio, desc: "SAMPLE LIBRARIES Digital Vision"} - - {id: 59, cat: Audio, desc: "SAMPLE LIBRARIES Discovery Firm"} - - {id: 61, cat: Audio, desc: "SAMPLE LIBRARIES Drumdrops"} - - {id: 40, cat: Audio, desc: "SAMPLE LIBRARIES East West"} - - {id: 74, cat: Audio, desc: "SAMPLE LIBRARIES Electronisounds"} - - {id: 80, cat: Audio, desc: "SAMPLE LIBRARIES EMU format"} - - {id: 82, cat: Audio, desc: "SAMPLE LIBRARIES ENSONIQ format"} - - {id: 111, cat: Audio, desc: "SAMPLE LIBRARIES Fatloud"} - - {id: 135, cat: Audio, desc: "SAMPLE LIBRARIES Fox Samples"} - - {id: 41, cat: Audio, desc: "SAMPLE LIBRARIES FxPansion"} - - {id: 130, cat: Audio, desc: "SAMPLE LIBRARIES Goldbaby"} - - {id: 54, cat: Audio, desc: "SAMPLE LIBRARIES Hollywood Edge"} - - {id: 64, cat: Audio, desc: "SAMPLE LIBRARIES IK Multimedia"} - - {id: 75, cat: Audio, desc: "SAMPLE LIBRARIES Ilio"} - - {id: 108, cat: Audio, desc: "SAMPLE LIBRARIES Industrial Strength Records"} - - {id: 81, cat: Audio, desc: "SAMPLE LIBRARIES KURZWEIL format"} - - {id: 91, cat: Audio, desc: "SAMPLE LIBRARIES Loopmasters"} - - {id: 92, cat: Audio, desc: "SAMPLE LIBRARIES Masterbits"} - - {id: 58, cat: Audio, desc: "SAMPLE LIBRARIES M-Audio"} - - {id: 112, cat: Audio, desc: "SAMPLE LIBRARIES Mutekki"} - - {id: 137, cat: Audio, desc: "SAMPLE LIBRARIES Mystic Kingz"} - - {id: 47, cat: Audio, desc: "SAMPLE LIBRARIES Native Instruments"} - - {id: 60, cat: Audio, desc: "SAMPLE LIBRARIES Nine Volt Audio"} - - {id: 129, cat: Audio, desc: "SAMPLE LIBRARIES OrangeTree"} - - {id: 203, cat: Audio, desc: "SAMPLE LIBRARIES Origin Sound"} - - {id: 154, cat: Audio, desc: "SAMPLE LIBRARIES P5Audio"} - - {id: 133, cat: Audio, desc: "SAMPLE LIBRARIES Peace Love Productions"} - - {id: 140, cat: Audio, desc: "SAMPLE LIBRARIES Pound Audio"} - - {id: 110, cat: Audio, desc: "SAMPLE LIBRARIES Prime Loops"} - - {id: 180, cat: Audio, desc: "SAMPLE LIBRARIES Producer Loops"} - - {id: 50, cat: Audio, desc: "SAMPLE LIBRARIES Prominy"} - - {id: 171, cat: Audio, desc: "SAMPLE LIBRARIES Pulsed Records"} - - {id: 132, cat: Audio, desc: "SAMPLE LIBRARIES Puremagnetik"} - - {id: 49, cat: Audio, desc: "SAMPLE LIBRARIES Refills"} - - {id: 99, cat: Audio, desc: "SAMPLE LIBRARIES reFX Nexus"} - - {id: 125, cat: Audio, desc: "SAMPLE LIBRARIES Realsamples"} - - {id: 83, cat: Audio, desc: "SAMPLE LIBRARIES ROLAND format"} - - {id: 109, cat: Audio, desc: "SAMPLE LIBRARIES Sample Magic"} - - {id: 136, cat: Audio, desc: "SAMPLE LIBRARIES Samplephonics"} - - {id: 43, cat: Audio, desc: "SAMPLE LIBRARIES Sampletekk"} - - {id: 48, cat: Audio, desc: "SAMPLE LIBRARIES Scarbee"} - - {id: 178, cat: Audio, desc: "SAMPLE LIBRARIES SFX (Sound FX libraries)"} - - {id: 90, cat: Audio, desc: "SAMPLE LIBRARIES Smartsound"} - - {id: 172, cat: Audio, desc: "SAMPLE LIBRARIES Sonart Audio"} - - {id: 116, cat: Audio, desc: "SAMPLE LIBRARIES SonicCouture"} - - {id: 42, cat: Audio, desc: "SAMPLE LIBRARIES Sonic Implants"} - - {id: 115, cat: Audio, desc: "SAMPLE LIBRARIES Sonic Reality"} - - {id: 53, cat: Audio, desc: "SAMPLE LIBRARIES Sound Ideas"} - - {id: 56, cat: Audio, desc: "SAMPLE LIBRARIES Sony"} - - {id: 139, cat: Audio, desc: "SAMPLE LIBRARIES Sounds To Sample"} - - {id: 76, cat: Audio, desc: "SAMPLE LIBRARIES Spectrasonics"} - - {id: 162, cat: Audio, desc: "SAMPLE LIBRARIES Spitfire Audio"} - - {id: 193, cat: Audio, desc: "SAMPLE LIBRARIES studiolinked"} - - {id: 68, cat: Audio, desc: "SAMPLE LIBRARIES Synthogy"} - - {id: 179, cat: Audio, desc: "SAMPLE LIBRARIES The Loop Loft"} - - {id: 96, cat: Audio, desc: "SAMPLE LIBRARIES Tonehammer, 8dio, SoundIron"} - - {id: 67, cat: Audio, desc: "SAMPLE LIBRARIES Toontrack, Drumkit From Hell, DFHS"} - - {id: 69, cat: Audio, desc: "SAMPLE LIBRARIES Ueberschall"} - - {id: 157, cat: Audio, desc: "SAMPLE LIBRARIES USB Soundscan"} - - {id: 120, cat: Audio, desc: "SAMPLE LIBRARIES Valentino Production"} - - {id: 173, cat: Audio, desc: "SAMPLE LIBRARIES vandalism sounds"} - - {id: 170, cat: Audio, desc: "SAMPLE LIBRARIES Vanilla Groove Studios"} - - {id: 119, cat: Audio, desc: "SAMPLE LIBRARIES Vengeance Sound"} - - {id: 114, cat: Audio, desc: "SAMPLE LIBRARIES Video Helper"} - - {id: 44, cat: Audio, desc: "SAMPLE LIBRARIES Vienna"} - - {id: 77, cat: Audio, desc: "SAMPLE LIBRARIES Vipzone"} - - {id: 131, cat: Audio, desc: "SAMPLE LIBRARIES Wavesfactory"} - - {id: 89, cat: Audio, desc: "SAMPLE LIBRARIES West one"} - - {id: 113, cat: Audio, desc: "SAMPLE LIBRARIES Wusik.com"} - - {id: 78, cat: Audio, desc: "SAMPLE LIBRARIES Yellowtools"} - - {id: 138, cat: Audio, desc: "SAMPLE LIBRARIES YnK.Audio"} - - {id: 100, cat: Audio, desc: "SAMPLE LIBRARIES Zenhiser"} - - {id: 57, cat: Audio, desc: "SAMPLE LIBRARIES Zero-G"} - - {id: 15, cat: PC, desc: "VSTi + FX"} - - {id: 72, cat: PC, desc: "VSTi + FX FX"} - - {id: 181, cat: PC, desc: "VSTi + FX VSTi"} - - {id: 18, cat: PC/Mac, desc: "MAC"} - - {id: 186, cat: PC/Mac, desc: "MAC Apps"} - - {id: 188, cat: PC/Mac, desc: "MAC FX"} - - {id: 189, cat: PC/Mac, desc: "MAC Instruments"} - - {id: 190, cat: PC/Mac, desc: "MAC TUTORIAL"} - - {id: 187, cat: PC/Mac, desc: "MAC Samples"} - - {id: 19, cat: Other, desc: "MISC"} - - {id: 185, cat: Other, desc: "MISC Utilities"} - - {id: 84, cat: Books, desc: "SHEET MUSIC"} - - {id: 87, cat: Books, desc: "SHEET MUSIC Full Score"} - - {id: 86, cat: Books, desc: "SHEET MUSIC Guitar"} - - {id: 88, cat: Books, desc: "SHEET MUSIC Other instruments"} - - {id: 85, cat: Books, desc: "SHEET MUSIC Piano"} - - {id: 20, cat: Books, desc: "LEARNING"} - - {id: 197, cat: Books, desc: "LEARNING Academy.FM"} - - {id: 183, cat: Books, desc: "LEARNING ADSR"} - - {id: 141, cat: Books, desc: "LEARNING Alfred"} - - {id: 142, cat: Books, desc: "LEARNING Ask Video"} - - {id: 27, cat: Books, desc: "LEARNING Books"} - - {id: 143, cat: Books, desc: "LEARNING FaderPro"} - - {id: 144, cat: Books, desc: "LEARNING Groove3"} - - {id: 145, cat: Books, desc: "LEARNING Hal Leonard"} - - {id: 105, cat: Books, desc: "LEARNING Hardware manuals"} - - {id: 196, cat: Books, desc: "LEARNING Jam Track Central"} - - {id: 146, cat: Books, desc: "LEARNING Lick Library"} - - {id: 147, cat: Books, desc: "LEARNING Lynda.com"} - - {id: 148, cat: Books, desc: "LEARNING MacProVideo"} - - {id: 101, cat: Books, desc: "LEARNING Magazines"} - - {id: 93, cat: Books, desc: "LEARNING Music Video (Concerts, DVD, DVDrip)"} - - {id: 199, cat: Books, desc: "LEARNING PUREMIX"} - - {id: 149, cat: Books, desc: "LEARNING Riff Interactive"} - - {id: 194, cat: Books, desc: "LEARNING SkillShare"} - - {id: 150, cat: Books, desc: "LEARNING Sonic Academy"} - - {id: 151, cat: Books, desc: "LEARNING Total training"} - - {id: 152, cat: Books, desc: "LEARNING TrueFire"} - - {id: 182, cat: Books, desc: "LEARNING Udemy"} - - {id: 28, cat: Books, desc: "LEARNING Video"} - - {id: 153, cat: Books, desc: "LEARNING VTC"} - - {id: 29, cat: Audio/Other, desc: "PRESETS"} - - {id: 167, cat: Audio/Other, desc: "PRESETS Ableton Live"} - - {id: 184, cat: Audio/Other, desc: "PRESETS Impulse Responses"} - - {id: 166, cat: Audio/Other, desc: "PRESETS Massive"} - - {id: 163, cat: Audio/Other, desc: "PRESETS Nebula"} - - {id: 200, cat: Audio/Other, desc: "PRESETS Omnisphere"} - - {id: 165, cat: Audio/Other, desc: "PRESETS Reaktor"} - - {id: 201, cat: Audio/Other, desc: "PRESETS Xfer Serum"} - - {id: 164, cat: Audio/Other, desc: "PRESETS Sylenth"} - - {id: 168, cat: Audio/Other, desc: "PRESETS Toontrack"} - - {id: 239, cat: Books, desc: "Service Manuals"} - - {id: 21, cat: Other, desc: "UPDATE"} - - {id: 222, cat: Other, desc: "GFXNEWS 3D"} - - {id: 235, cat: Other, desc: "GFXNEWS APPS ANDROID & IOS"} - - {id: 207, cat: Other, desc: "GFXNEWS APPS WIN"} - - {id: 215, cat: Other, desc: "GFXNEWS APPS LINUX"} - - {id: 214, cat: Other, desc: "GFXNEWS APPS MAC"} - - {id: 211, cat: Other, desc: "GFXNEWS BOOKS"} - - {id: 224, cat: Other, desc: "GFXNEWS CAD/CAM"} - - {id: 237, cat: Other, desc: "GFXNEWS DAZ3D"} - - {id: 212, cat: Other, desc: "GFXNEWS FONTS"} - - {id: 213, cat: Other, desc: "GFXNEWS FOOTAGE"} - - {id: 233, cat: Other, desc: "GFXNEWS GAME DEVELOPMENT"} - - {id: 226, cat: Other, desc: "GFXNEWS MAGAZINE"} - - {id: 234, cat: Other, desc: "GFXNEWS MATERIAL"} - - {id: 243, cat: Other, desc: "GFXNEWS OTHER GRAPHICS"} - - {id: 244, cat: Other, desc: "GFXNEWS OTHER GRAPHICS Zbrush, Substance tools"} - - {id: 216, cat: Other, desc: "GFXNEWS PHOTOSTOCK"} - - {id: 220, cat: Other, desc: "GFXNEWS PHOTOGRAPHY - ART"} - - {id: 227, cat: Other, desc: "GFXNEWS 2D Graphics"} - - {id: 250, cat: Other, desc: "GFXNEWS 2D Graphics Effects"} - - {id: 228, cat: Other, desc: "GFXNEWS 2D Graphics Actions, Pattern & Styles"} - - {id: 229, cat: Other, desc: "GFXNEWS 2D Graphics Background & PSD Source"} - - {id: 230, cat: Other, desc: "GFXNEWS 2D Graphics Brushes & Gradient"} - - {id: 246, cat: Other, desc: "GFXNEWS 2D Graphics Mockup"} - - {id: 247, cat: Other, desc: "GFXNEWS 2D Graphics Overlays"} - - {id: 249, cat: Other, desc: "GFXNEWS 2D Graphics Preset"} - - {id: 231, cat: Other, desc: "GFXNEWS 2D Graphics Toolkits, Papers & Objects"} - - {id: 245, cat: Other, desc: "GFXNEWS GFX Plugins"} - - {id: 210, cat: Other, desc: "GFXNEWS PLUGINS WIN"} - - {id: 223, cat: Other, desc: "GFXNEWS PLUGINS MAC"} - - {id: 225, cat: Other, desc: "GFXNEWS PLUGINS LINUX"} - - {id: 217, cat: Other, desc: "GFXNEWS SCRIPTS - WEB PROGRAMMING"} - - {id: 218, cat: Other, desc: "GFXNEWS TEMPLATES"} - - {id: 232, cat: Other, desc: "GFXNEWS TEXTURES"} - - {id: 219, cat: Other, desc: "GFXNEWS TUTORIALS"} - - {id: 238, cat: Other, desc: "GFXNEWS VECTOR GRAPHICS"} - - {id: 221, cat: Other, desc: "GFXNEWS VIDEO PRODUCTION"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must stay active on the site...
  1. Any New users that have no stats for one month will have their account deleted
  2. Any disabled accounts will be deleted after 6 months
  3. Any accounts inactive for more than 6 months will be deleted
  4. Donor accounts will be left untouched.
" - -login: - path: login.php - method: post - inputs: - "login_username": "{{ .Config.username }}" - "login_password": "{{ .Config.password }}" - autologin: 1 - login: "Log in" - redirect: "/" - error: - - selector: table.error - message: - selector: table.error tr td div.msg - test: - path: / - selector: li a[href^="./login.php?logout=1"] - -search: - paths: - - path: tracker.php - method: post - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 1 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - pre_tor_type: 0 - prev_date_from: "" - prev_date_to: "" - prev_hide_rereg: 0 - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - df: 1 - da: 1 - nm: "{{ .Keywords }}" - pn: "" - submit: "Search" - - rows: - selector: table.forumline > tbody > tr:has(a[href*="/dl.php?id="]) - dateheaders: - selector: td[colspan="20"] > strong - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "MM-dd-yyyy zzz" - - fields: - title: - selector: td a[href*="/viewtopic.php?t="] - details: - selector: td a[href*="/viewtopic.php?t="] - attribute: href - download: - selector: td a[href*="/dl.php?id="] - attribute: href - category: - selector: td a[href*="/tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - size: - selector: td:contains(\00a0GB), td:contains(\00a0MB), td:contains(\00a0KB), td:contains(\00a0B) - optional: true - default: 0 - seeders: - selector: td[title="Seeders"] - optional: true - default: 0 - leechers: - selector: td[title="Leechers"] - optional: true - default: 0 - grabs: - selector: td[title^="Replies:"] - optional: true - default: 0 - description: - case: - span.tor-approved: Verified - span.tor-not-approved: Unverified - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.8 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aura4k-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aura4k-api.yml deleted file mode 100644 index 3c494637..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aura4k-api.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: aura4k-api -name: AURA4K (API) -description: "AURA4K is a Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://aura4k.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your AURA4K account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts with no activity (login/traffic) for more than 90 days may be automatically pruned." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 72 hours OR 1:1 - minimumratio: - text: 1.0 - minimumseedtime: - # 72 hours (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aussierules.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aussierules.yml deleted file mode 100644 index 9e0bebd5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/aussierules.yml +++ /dev/null @@ -1,92 +0,0 @@ ---- -id: aussierules -name: Aussierul.es -description: "AussieRul.es is a Private Torrent Tracker for AUSTRALIAN RULES FOOTBALL" -language: en-AU -type: private -encoding: UTF-8 -links: - - https://aussierul.es/ - -caps: - categories: - 1: TV/Sport - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: type - label: Search Type - type: select - default: t_name - options: - t_name: Torrent Name - t_description: Torrent Description - t_both: Name & Description - t_uploader: Uploader - t_genre: IMDB Genre - - name: dead - label: Include Dead Torrents - type: select - default: no - options: - yes: Yes - no: No - -login: - method: form - form: form - path: login.php - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table:contains("An error has occured!") - test: - path: browse.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - inputs: - search_type: "{{ .Config.type }}" - include_dead_torrents: "{{ .Config.dead }}" - keywords: "{{ .Keywords }}" - - rows: - selector: table#sortabletable > tbody > tr:not(:first-child) - - fields: - category: - text: 1 - title: - selector: td:nth-child(2) > div:nth-child(1) > a > strong - details: - selector: td:nth-child(2) > div:nth-child(1) > a - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - date: - # 27-03-2021 18:34 - selector: td:nth-child(2) > div:nth-child(2) - filters: - - name: dateparse - args: "d-MM-yyyy HH:mm" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) -# TS Special Edition v.7.4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/azusa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/azusa.yml deleted file mode 100644 index c394a19e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/azusa.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: azusa -name: Azusa (梓喵) -description: "Azusa (梓喵) is a CHINESE Private Torrent Tracker focusing on COMICS" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://azusa.wiki/ - -caps: - categorymappings: - - {id: 404, cat: PC/Games, desc: "Game"} - - {id: 402, cat: Books/Comics, desc: "Comic"} - - {id: 403, cat: Books/EBook, desc: "Light Novel"} - - {id: 407, cat: Other, desc: "CG"} - - {id: 409, cat: Audio, desc: "Music"} - - modes: - search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="logout.php?csrf_token="] - -search: - paths: - - path: torrents.php - allowEmptyInputs: true - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - inclbookmarked: 0 - approval_status: "" - trackerStatus: "" - search: "{{ .Keywords }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: a[href$="dllist=1#seeders"] - optional: true - default: 0 - leechers: - selector: a[href$="dllist=1#leechers"] - optional: true - default: 0 - grabs: - selector: a[href^="viewsnatches.php?id="] - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.7.20 2022-08-05 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/backups.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/backups.yml deleted file mode 100644 index 9c440995..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/backups.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: backups -name: Back-ups -description: "Back-Ups is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://back-ups.me/ - -caps: - categorymappings: - - {id: 115, cat: PC, desc: "2Play! Official"} - - {id: 100, cat: Console, desc: "Anbernic"} - - {id: 15, cat: PC/Mobile-Android, desc: "Android"} - - {id: 35, cat: TV/Anime, desc: "Anime"} - - {id: 3, cat: PC, desc: "Apps"} - - {id: 109, cat: PC, desc: "Attract"} - - {id: 64, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 71, cat: Console, desc: "Batocera"} - - {id: 108, cat: Console, desc: "Bazpacks"} - - {id: 4, cat: Movies/BluRay, desc: "Blu-Ray"} - - {id: 112, cat: Console, desc: "Brojack & Laws"} - - {id: 42, cat: Console, desc: "CoinOPS Official"} - - {id: 65, cat: Console, desc: "CoinOps Unofficial"} - - {id: 22, cat: Console, desc: "CoinOps Xbox"} - - {id: 63, cat: Books/Comics, desc: "Comics"} - - {id: 60, cat: Console, desc: "CORE"} - - {id: 72, cat: Console, desc: "CORE Community"} - - {id: 44, cat: Console, desc: "CP78"} - - {id: 16, cat: TV/Documentary, desc: "Docu"} - - {id: 41, cat: Console, desc: "Dragon"} - - {id: 5, cat: Books, desc: "eBooks"} - - {id: 89, cat: PC, desc: "Ghostware"} - - {id: 84, cat: TV, desc: "GOG"} - - {id: 77, cat: PC, desc: "Hyperspin"} - - {id: 34, cat: TV, desc: "Kids"} - - {id: 107, cat: Console, desc: "KinHanK"} - - {id: 43, cat: Console, desc: "LaunchBox"} - - {id: 85, cat: PC, desc: "MAME"} - - {id: 32, cat: Movies, desc: "Movie Pack"} - - {id: 106, cat: Movies/UHD, desc: "Movie Pack 4K"} - - {id: 6, cat: Movies, desc: "Movies"} - - {id: 105, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 82, cat: PC, desc: "MS-DOS"} - - {id: 7, cat: Audio, desc: "Music"} - - {id: 31, cat: Console, desc: "Nintendo"} - - {id: 59, cat: Console, desc: "P-R-U"} - - {id: 8, cat: PC/Games, desc: "PC Games"} - - {id: 87, cat: Console, desc: "Playnite"} - - {id: 30, cat: Console, desc: "Playstation"} - - {id: 83, cat: PC, desc: "Retro eXo"} - - {id: 56, cat: PC, desc: "Retro Minis"} - - {id: 67, cat: PC, desc: "RetroBat"} - - {id: 88, cat: PC, desc: "RetroFE"} - - {id: 76, cat: Console, desc: "RGS"} - - {id: 27, cat: PC, desc: "ROM Packs"} - - {id: 62, cat: PC, desc: "RPi"} - - {id: 81, cat: PC, desc: "Scene"} - - {id: 68, cat: PC, desc: "Schmoomer"} - - {id: 110, cat: Console, desc: "ScummVM"} - - {id: 57, cat: Console, desc: "SEGA"} - - {id: 116, cat: PC, desc: "SiteRIP"} - - {id: 74, cat: PC, desc: "Sleyk/SGC"} - - {id: 75, cat: Audio, desc: "Soundtracks"} - - {id: 29, cat: TV/Sport, desc: "Sport"} - - {id: 90, cat: PC, desc: "TeknoParrot"} - - {id: 97, cat: TV/Other, desc: "Tutorials"} - - {id: 9, cat: TV, desc: "TV Packs"} - - {id: 11, cat: TV, desc: "TV Shows"} - - {id: 73, cat: Console, desc: "Wolfanoz"} - - {id: 26, cat: Console, desc: "XBOX"} - - {id: 12, cat: Console, desc: "XBOX360"} - - {id: 23, cat: XXX, desc: "XXX18+"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 40 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "To keep records updated regularly, all inactive accounts will be deleted after 60 days of inactivity." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td:contains("remaining tries") - test: - path: browse.php - selector: a[href*="/logout.php"] - -search: - paths: - - path: browse.php - keywordsfilters: - # remove words <= 3 characters and surrounding special characters - - name: re_replace - args: ["(?:^|\\s)[_\\+\\/\\.\\-\\(\\)]*[\\S]{0,3}[_\\+\\/\\.\\-\\(\\)]*(?:\\s|$)", " "] - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_name - category: 0 # multi cat search not supported - # yes, no - include_dead_torrents: yes - # sort is done via do=quick_sort, and freeleech sorting is part of that set - - rows: - selector: "table#sortabletable > tbody > tr:has(a[href*=\"/details.php?id=\"]){{ if .Config.freeleech }}:has(img[src$=\"freedownload.gif\"]){{ else }}{{ end }}" - filters: - - name: andmatch - args: 66 - - fields: - category: - selector: a[href*="/browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - title_default: - selector: a[href*="/details.php?id="] - title: - selector: div.tooltip-content > div - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/download.php?id="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - poster: - selector: div.tooltip-content > img - attribute: src - downloadvolumefactor: - case: - img[src$="freedownload.gif"]: 0 - img[src$="silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="x2.gif"]: 2 - "*": 1 - date: - selector: "td:nth-child(2) > div:has(span[style=\"float: right;\"])" - remove: span - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "dd-MM-yyyy HH:mm zzz" - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TS Special Edition v.8.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bangumi-moe.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bangumi-moe.yml deleted file mode 100644 index a5c459c0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bangumi-moe.yml +++ /dev/null @@ -1,81 +0,0 @@ ---- -id: bangumi-moe -name: Bangumi Moe -description: "Bangumi Moe is a Public torrent site for ANIME" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 4 -links: - - https://bangumi.moe/ - -caps: - categorymappings: - - {id: 549ef207fe682f7549f1ea90, cat: TV/Anime, desc: "Anime"} - - {id: 54967e14ff43b99e284d0bf7, cat: TV/Anime, desc: "Anime Pack"} - - {id: 549cc9369310bc7d04cddf9f, cat: Movies, desc: "Anime Movie"} - - {id: 549eef6ffe682f7549f1ea8b, cat: Audio, desc: "Music"} - - {id: 549eefebfe682f7549f1ea8c, cat: Books/Comics, desc: "Comic"} - - {id: 549ef015fe682f7549f1ea8d, cat: Console, desc: "Game"} - - {id: 549ef250fe682f7549f1ea91, cat: Other, desc: "Other"} - - {id: 549ff1db30bcfc225bf9e607, cat: Console, desc: "Dorama"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -search: - paths: - # https://bangumi.moe/api/torrent/latest - # https://bangumi.moe/api/v2/torrent/search - - path: "{{ if .Keywords }}api/v2/torrent/search{{ else }}api/torrent/latest{{ end }}" - method: "{{ if .Keywords }}post{{ else }}get{{ end }}" - response: - type: json - - inputs: -# $raw: "{{ if .Keywords }}query={{ .Keywords }}{{ else }}{{ end }}" - query: "{{ .Keywords }}" - - rows: - selector: torrents - - fields: - category: - selector: category_tag_id - title: - selector: title - details: - text: / - infohash: - selector: infoHash - poster: - selector: introduction - filters: - - name: regexp - args: "src=\\\"(.+?)\\\"" - description: - selector: category_tag.locale.en - date: - # 2021-10-19T06:10:15.772Z - selector: publish_time - size: - selector: size - optional: true - default: 0 B - grabs: - selector: downloads - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json api v2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/baozipt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/baozipt.yml deleted file mode 100644 index 464879e3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/baozipt.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: baozipt -name: baoziPT -description: "baoziPT is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://p.t-baozi.cc/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影/Movies"} - - {id: 402, cat: TV, desc: "剧集/TV Series"} - - {id: 403, cat: TV, desc: "综艺/TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentaries"} - - {id: 405, cat: TV/Anime, desc: "动漫/Anime"} - - {id: 406, cat: Audio/Video, desc: "音乐视频/Music Videos"} - - {id: 407, cat: TV/Sport, desc: "体育运动/Sports"} - - {id: 408, cat: Audio, desc: "高品质音频/HQ Audio"} - - {id: 409, cat: Other, desc: "其他/Other"} - - {id: 410, cat: TV, desc: "短剧/Playlet"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/beload.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/beload.yml deleted file mode 100644 index c6644d72..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/beload.yml +++ /dev/null @@ -1,161 +0,0 @@ ---- -id: beload -name: Beload -description: "Beload is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://beload.org/ - -caps: - categorymappings: - - {id: 27, cat: Movies/DVD, desc: "Animáció/DVD/Eng"} - - {id: 26, cat: Movies/DVD, desc: "Animáció/DVD/Hun"} - - {id: 25, cat: Movies/HD, desc: "Animáció/HD/Eng"} - - {id: 24, cat: Movies/HD, desc: "Animáció/HD/Hun"} - - {id: 28, cat: Movies/SD, desc: "Animáció/SD/Hun"} - - {id: 12, cat: Movies/SD, desc: "Film/Cam/Eng"} - - {id: 11, cat: Movies/SD, desc: "Film/Cam/Hun"} - - {id: 4, cat: Movies/DVD, desc: "Film/DVD-R/Eng"} - - {id: 3, cat: Movies/DVD, desc: "Film/DVD-R/Hun"} - - {id: 6, cat: Movies/DVD, desc: "Film/DVD9/Eng"} - - {id: 5, cat: Movies/DVD, desc: "Film/DVD9/Hun"} - - {id: 2, cat: Movies/Foreign, desc: "Film/HD/Eng"} - - {id: 1, cat: Movies/HD, desc: "Film/HD/Hun"} - - {id: 8, cat: Movies/Foreign, desc: "Film/SD/Eng"} - - {id: 7, cat: Movies/SD, desc: "Film/SD/Hun"} - - {id: 47, cat: Movies/Foreign, desc: "Film/UHD/Eng"} - - {id: 46, cat: Movies/UHD, desc: "Film/UHD/Hun"} - - {id: 31, cat: Audio/Audiobook, desc: "Hangoskönyv"} - - {id: 42, cat: PC/Games, desc: "Játék/ISO"} - - {id: 43, cat: PC/Games, desc: "Játék/RIP"} - - {id: 30, cat: Books/Foreign, desc: "Könyv/Eng"} - - {id: 29, cat: Books/EBook, desc: "Könyv/Hun"} - - {id: 44, cat: PC/ISO, desc: "Program/ISO"} - - {id: 45, cat: PC, desc: "Program/RIP"} - - {id: 49, cat: TV/Foreign, desc: "Sorozat/DVD/Eng"} - - {id: 48, cat: TV/SD, desc: "Sorozat/DVD/Hun"} - - {id: 14, cat: TV/Foreign, desc: "Sorozat/HD/Eng"} - - {id: 13, cat: TV/HD, desc: "Sorozat/HD/Hun"} - - {id: 16, cat: TV/Foreign, desc: "Sorozat/SD/Eng"} - - {id: 15, cat: TV/SD, desc: "Sorozat/SD/hun"} - - {id: 20, cat: Audio/Foreign, desc: "Zene/Mp3/Eng"} - - {id: 19, cat: Audio/MP3, desc: "Zene/Mp3/Hun"} - - {id: 38, cat: XXX/SD, desc: "XXX/HD"} - - {id: 41, cat: XXX/ImageSet, desc: "XXX/Kép"} - - {id: 40, cat: XXX/SD, desc: "XXX/SD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 1: title - 4: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="/logout.php"] - -search: - paths: - - path: browse.php - - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 both, 2 dead, 3 my uploa, 4 waiting for seeds - incldead: 1 - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # does not support imdbid searches - - rows: - selector: "div.torrents-container > table > tbody > tr > td.text > table > tbody > tr:has(a[href^=\"details.php?id=\"]):not(:has(a[href=\"browse.php?cat=0\"]))" - after: 1 - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="][title] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: a.preview - attribute: href - date: - selector: td:nth-last-child(6) > a - attribute: title - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - files: - selector: td:nth-last-child(8) - size: - selector: td:nth-last-child(5) b - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - description: - case: - i.fa-check: Verified - i.fa-question: Unverified - downloadvolumefactor: - case: - "span[style=\"font-size: 10px;\"]:contains(\"0x\")": 0 - "*": 1 - uploadvolumefactor: - case: - "span[style=\"font-size: 10px;\"]:contains(\"2x\")": 2 - "span[style=\"font-size: 10px;\"]:contains(\"4x\")": 4 - "*": 1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/best-torrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/best-torrents.yml deleted file mode 100644 index e24aa7eb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/best-torrents.yml +++ /dev/null @@ -1,120 +0,0 @@ ---- -id: best-torrents -name: Best-Torrents -description: "Best-Torrents is a POLISH Semi-Private PAY2DL Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: semi-private -encoding: UTF-8 -links: - - https://best-torrents.com/ - -caps: - categorymappings: - - {id: 774, cat: Movies, desc: Filmy Polskie} - - {id: 642, cat: Movies, desc: Filmy/Bajki} - - {id: 1, cat: Movies/SD, desc: Filmy XviD/DivX} - - {id: 20, cat: Movies/DVD, desc: Filmy DVD} - - {id: 23, cat: Movies/HD, desc: Filmy x264} - - {id: 573, cat: Movies/UHD, desc: Filmy 4K UHD} - - {id: 17, cat: Movies/HD, desc: Filmy HD} - - {id: 265, cat: Movies/HD, desc: Filmy x265 HEVC} - - {id: 12, cat: Movies/3D, desc: Filmy 3D} - - {id: 530, cat: Movies/SD, desc: Filmy TS/CAM} - - {id: 9, cat: PC, desc: Programy} - - {id: 7, cat: Console, desc: Konsole} - - {id: 6, cat: PC/Games, desc: Gry} - - {id: 5, cat: Audio, desc: Muzyka} - - {id: 305, cat: Audio/Video, desc: Teledyski} - - {id: 22, cat: TV, desc: TV} - - {id: 228, cat: XXX, desc: Erotyka} - - {id: 25, cat: Books/EBook, desc: Książki} - - {id: 739, cat: Audio/Audiobook, desc: AudioBooki} - - {id: 26, cat: Books/Comics, desc: Komiksy} - - {id: 27, cat: PC/Mac, desc: Linux/Mac} - - {id: 28, cat: TV/Sport, desc: Sport} - - {id: 29, cat: TV/Anime, desc: Anime} - - {id: 31, cat: PC/Mobile-Other, desc: GSM/PDA} - - {id: 32, cat: Other, desc: Inne} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.komunikaty:contains("Nazwa użytkownika bądź hasło jest nieprawidłowe") - test: - path: / - selector: a[href$="/logout.php"] - -search: - # https://best-torrents.com/szukaj.php?search=%25&cat=774&full_name=no - paths: - - path: szukaj.php - inputs: - # does not appear to support multi category searches - cat: 0 - # no, 0 Standard mode, 1 Initial mode, 2 Full Phrase mode, 3 Final mode - full_name: no - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}%{{ end }}" - - rows: - selector: div#torrents_browse-standard-all - - fields: - categorydesc: - selector: div#torrents_browse-standard-category a - attribute: href - filters: - - name: regexp - args: kategorii/(.+?)/gatunku - title: - selector: div#torrents_browse-standard-torrent-name a - details: - selector: div#torrents_browse-standard-torrent-name a - attribute: href - download: - selector: div#torrents_browse-standard-download a - attribute: href - poster: - selector: div#torrents_browse-standard-poster img - attribute: src - date: - selector: div#torrents_browse-standard-time - remove: b - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: div#torrents_browse-standard-size - remove: b - filters: - - name: regexp - args: (\d{1,4}\.\d{2}\s+?[T|G|M|k]B) - seeders: - selector: div#torrents_browse-standard-seeds span font - leechers: - selector: div#torrents_browse-standard-peers span font - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bestcore.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bestcore.yml deleted file mode 100644 index c33675a4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bestcore.yml +++ /dev/null @@ -1,209 +0,0 @@ ---- -id: bestcore -name: Best-Core -description: "Best-Core is a HUNGARIAN Private Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: ISO-8859-2 -links: - - https://best-core.info/ - -caps: - categorymappings: - - {id: 81, cat: Movies/3D, desc: "3D"} - - {id: 83, cat: Movies/UHD, desc: "4K/UHD"} - - {id: 80, cat: Movies/Other, desc: "Best-Core"} - - {id: 51, cat: Books/EBook, desc: "E-book"} - - {id: 88, cat: Audio/Audiobook, desc: "E-book/Hangoskönyv"} - - {id: 77, cat: Movies/HD, desc: "Film/BDRip/Eng"} - - {id: 86, cat: Movies/HD, desc: "Film/BDRip/Ger"} - - {id: 76, cat: Movies/HD, desc: "Film/BDRip/Hun"} - - {id: 75, cat: Movies/BluRay, desc: "Film/Blu-ray/Eng"} - - {id: 74, cat: Movies/BluRay, desc: "Film/Blu-ray/Hun"} - - {id: 25, cat: Movies/DVD, desc: "Film/DVD/Eng"} - - {id: 24, cat: Movies/DVD, desc: "Film/DVD/Hun"} - - {id: 85, cat: Movies/DVD, desc: "Film/DVD9"} - - {id: 21, cat: Movies/HD, desc: "Film/HD/Eng"} - - {id: 53, cat: Movies/HD, desc: "Film/HD/Hun"} - - {id: 20, cat: Movies/SD, desc: "Film/XviD/Eng"} - - {id: 34, cat: Movies/SD, desc: "Film/XviD/Hun"} - - {id: 90, cat: Movies/SD, desc: "Film/XviD/Pack"} - - {id: 26, cat: PC/Games, desc: "Játék/ISO"} - - {id: 52, cat: PC/Games, desc: "Játék/Rip"} - - {id: 89, cat: Movies/SD, desc: "Kamerás/Eng"} - - {id: 82, cat: Movies/SD, desc: "Kamerás/Hun"} - - {id: 38, cat: Other, desc: "Képek"} - - {id: 43, cat: Console/PS3, desc: "Konzol/PS"} - - {id: 45, cat: Console/PSP, desc: "Konzol/PSP"} - - {id: 44, cat: Console/XBox, desc: "Konzol/Xbox"} - - {id: 73, cat: TV/Anime, desc: "Mese/Eng"} - - {id: 70, cat: TV/Anime, desc: "Mese/Hun"} - - {id: 37, cat: PC/Mobile-Other, desc: "Mobil"} - - {id: 1, cat: PC/ISO, desc: "Program/ISO"} - - {id: 22, cat: PC/0day, desc: "Program/Rip"} - - {id: 54, cat: TV/SD, desc: "Sorozat/DVD/Eng"} - - {id: 55, cat: TV/SD, desc: "Sorozat/DVD/Hun"} - - {id: 58, cat: TV/SD, desc: "Sorozat/Xvid/Eng"} - - {id: 7, cat: TV/SD, desc: "Sorozat/Xvid/Hun"} - - {id: 62, cat: Audio/Video, desc: "Videoklip"} - - {id: 64, cat: XXX/DVD, desc: "XXX/DVD"} - - {id: 48, cat: XXX/ImageSet, desc: "XXX/Képek"} - - {id: 9, cat: XXX/XviD, desc: "XXX/Xvid"} - - {id: 40, cat: Audio/MP3, desc: "Zene/Eng"} - - {id: 6, cat: Audio/MP3, desc: "Zene/Hun"} - - {id: 87, cat: Audio/Lossless, desc: "Zene/Lossless"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - book-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search FreeLeech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 1: title - 4: added - 5: size - 7: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrent oldalankén: setting to 100 on your account profile. - - name: info_login - type: info - label: About login error - default: If you get a Login Failed, got redirected. error during your config save then most likely you have used an incorrect username or password. - - name: info_activity - type: info - label: Account Inactivity - default: "
  1. Users who have not accessed the site for 180 days will be deleted!
  2. If the vacation mode is set, the system will not delete the given user for 365 days.
" - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: :root:contains("Sikertelen bejelentkezés") - test: - path: index.php - selector: a[href^="logout.php?c="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid search - # 0 active, 1 all, 2 deadonly, 3 freeleech - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: tr.torrent_h - filters: - - name: andmatch - - fields: - category_optional: - selector: a[href^="browse.php?cat="] - attribute: href - optional: true - filters: - - name: querystring - args: cat - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}38{{ end }}" - title_default: - # can be abbreviated - selector: a[href^="details.php?id="] - title: - # usually full length - selector: a[href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.*?) " - - name: replace - args: ["https://i.kek.sh/null", ""] - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(2) - filters: - - name: regexp - args: "(\\d{4}-\\d{2}-\\d{2}\\s\\d{2}:\\d{2}:\\d{2})" - - name: append - args: " +01:00" # CET - - name: replace - args: ["\xA0", " "] - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - files: - selector: td:nth-child(5) - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(7) - filters: - - name: regexp - args: x \d(\d+) x$ - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - description: - case: - img[src="pic/yes.png"]: Verified - img[src="pic/nincs.png"]: Unverified - downloadvolumefactor: - case: - img[src="pic/free.png"]: 0 - "*": 1 - uploadvolumefactor: - selector: td:nth-child(7) b - filters: - - name: regexp - args: (\d+) - minimumratio: - text: 0.8 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# custom v3.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bibliotik.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bibliotik.yml deleted file mode 100644 index 05ea9f69..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bibliotik.yml +++ /dev/null @@ -1,141 +0,0 @@ ---- -id: bibliotik -name: Bibliotik -description: "Bibliotik is a Private Torrent Tracker for EBOOKS and AUDIOBOOKS" -language: en-US -type: private -encoding: UTF-8 -links: - - https://bibliotik.me/ - -caps: - categorymappings: - - {id: 1, cat: PC, desc: "Applications"} - - {id: 3, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 4, cat: Books/Comics, desc: "Comics"} - - {id: 5, cat: Books/EBook, desc: "eBooks"} - - {id: 7, cat: Books/Mags, desc: "Magazines"} - - modes: - search: [q] - book-search: [q, title, author] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: orderby - type: select - label: Sort requested from site - default: added - options: - "@relevance": relevance - title: title - size: size - added: added - seeders: seeders - - name: order - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: / - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - login: "Log In!" - error: - - selector: center:first-of-type - test: - path: torrents/ - selector: div#pre_header - -search: - paths: - - path: torrents/ - inputs: - search: "{{ if .Query.Author }} @authors {{ .Query.Author }}{{ else }}{{ end }}{{ if .Query.Title }} @title {{ .Query.Title }}{{ else }}{{ end }}{{ .Keywords }}" - $raw: "{{ range .Categories }}cat[]={{.}}&{{end}}" - orderby: "{{ .Config.orderby }}" - order: "{{ .Config.order }}" - - rows: - selector: table#torrents_table > tbody > tr:has(.title) - - fields: - category: - selector: td:first-child - case: - div[title="Applications"]: 1 - div[title="Audiobooks"]: 3 - div[title="Comics"]: 4 - div[title="Ebooks"]: 5 - div[title="Magazines"]: 7 - _editor: - selector: .editorLink - optional: true - author: - selector: .authorLink - optional: true - default: "{{ .Result._editor }}" - year: - selector: .torYear - optional: true - _filetype: - selector: .torFormat - optional: true - _retail: - selector: .torRetail - optional: true - booktitle: - selector: .title a - title: - text: "{{ .Result.booktitle }}" - filters: - - name: append - args: "{{ if .Result.author }} by {{ .Result.author }}{{ else }}{{ end }}{{ if .Result.year }} {{ .Result.year }}{{ else }}{{ end }}{{ if .Result._filetype }} {{ .Result._filetype }}{{ else }}{{ end }}{{ if .Result._retail }} {{ .Result._retail }}{{ else }}{{ end }}" - details: - selector: .title a - attribute: href - date: - selector: .t_files_size_added time - attribute: datetime - optional: true - default: now - download: - selector: a[title="Download"] - attribute: href - files: - selector: .t_files_size_added - filters: - - name: regexp - args: "^\\s*(\\d+)\\s*file" - size: - selector: .t_files_size_added span - attribute: data-bytecount - seeders: - selector: .seeders - optional: true - default: 0 - leechers: - selector: .leechers - optional: true - default: 0 - grabs: - selector: .snatches - optional: true - default: 0 - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigbbs.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigbbs.yml deleted file mode 100644 index 88165bb3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigbbs.yml +++ /dev/null @@ -1,259 +0,0 @@ ---- -id: bigbbs -name: BigBBS -description: "BigBBS is a POLISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: private -encoding: UTF-8 -links: - - https://bigbbs.eu/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmi"} - - {id: 14, cat: Movies/SD, desc: "Filmi DivX / XviD"} - - {id: 41, cat: Movies/HD, desc: "Filmi x264"} - - {id: 40, cat: Movies, desc: "Filmi Al / Lektor Amatorski"} - - {id: 39, cat: Movies/BluRay, desc: "Filmi BluRay"} - - {id: 147, cat: Movies, desc: "Filmi Xmas"} - - {id: 37, cat: Movies/DVD, desc: "Filmi DVD 5 / 9"} - - {id: 52, cat: Movies/3D, desc: "Filmi 3D"} - - {id: 17, cat: Movies/HD, desc: "Filmi HD 1080p , 720p"} - - {id: 99, cat: Movies/HD, desc: "Filmi x265"} - - {id: 62, cat: XXX, desc: "Filmi XXX"} - - {id: 16, cat: Movies/SD, desc: "Filmi CAM / TS"} - - {id: 65, cat: Movies/HD, desc: "Filmi Rmvb"} - - {id: 66, cat: Movies, desc: "Filmi TV RIP"} - - {id: 84, cat: TV/Documentary, desc: "Filmi Dokumentalne"} - - {id: 100, cat: Movies, desc: "Filmi Seriale"} - - {id: 103, cat: Movies, desc: "Filmi FILMY GSM / PDA"} - - {id: 107, cat: Movies/UHD, desc: "Filmi 4K-UHD"} - - {id: 109, cat: Movies, desc: "Filmi Biblijny"} - - {id: 113, cat: Movies, desc: "Filmi Prawniczy"} - - {id: 61, cat: Movies, desc: "Bajki"} - - {id: 106, cat: Books/Comics, desc: "Manga"} - - {id: 53, cat: TV/Anime, desc: "Anime"} - - {id: 56, cat: TV, desc: "TV"} - - {id: 57, cat: TV, desc: "TV BOXSETS"} - - {id: 58, cat: TV, desc: "TV EPIZODY"} - - {id: 6, cat: PC, desc: "Aplikacje"} - - {id: 18, cat: PC/0day, desc: "Aplikacje Windows"} - - {id: 64, cat: PC/Mobile-Other, desc: "Aplikacje GSM/PDA"} - - {id: 19, cat: PC/Mac, desc: "Aplikacje Macintosh"} - - {id: 42, cat: PC, desc: "Aplikacje Linux"} - - {id: 63, cat: TV/Sport, desc: "Sport"} - - {id: 7, cat: Audio, desc: "Muzyka"} - - {id: 20, cat: Audio/Video, desc: "Koncert"} - - {id: 144, cat: Audio, desc: "BiGBBS RMG (Rellase Music Group)"} - - {id: 21, cat: Audio/MP3, desc: "MP3"} - - {id: 38, cat: Audio/Lossless, desc: "FLAC"} - - {id: 135, cat: Audio, desc: "RetroRemix,ClubDance"} - - {id: 43, cat: Audio, desc: "Soundtrack"} - - {id: 136, cat: Audio, desc: "Rock"} - - {id: 80, cat: Audio/Lossless, desc: "lossless"} - - {id: 81, cat: Audio, desc: "MusicVid"} - - {id: 82, cat: Audio, desc: "Radio"} - - {id: 138, cat: Audio, desc: "Dyskografie Płytowe"} - - {id: 117, cat: Audio, desc: "Metal Rock"} - - {id: 139, cat: Audio, desc: "Kolekcje Muzyczne"} - - {id: 118, cat: Audio, desc: "Disco Polo"} - - {id: 119, cat: Audio, desc: "Clubbing"} - - {id: 120, cat: Audio, desc: "House"} - - {id: 125, cat: Audio, desc: "Elektro"} - - {id: 127, cat: Audio, desc: "Tranc"} - - {id: 128, cat: Audio, desc: "Dance"} - - {id: 130, cat: Audio, desc: "Opus"} - - {id: 129, cat: Audio, desc: "Pop"} - - {id: 131, cat: Audio, desc: "Italo"} - - {id: 133, cat: Audio, desc: "ClubDance"} - - {id: 134, cat: Audio, desc: "Retro Remixes"} - - {id: 146, cat: Audio, desc: "Techno"} - - {id: 132, cat: Audio, desc: "eurodance"} - - {id: 145, cat: Audio, desc: "Chillout"} - - {id: 83, cat: Audio, desc: "BLUES / REGGAE/ ROCK / METAL/CLASSIC/"} - - {id: 2, cat: Console, desc: "Gry"} - - {id: 3, cat: Console/PS3, desc: "Sony PS"} - - {id: 4, cat: Console/Wii, desc: "Wii"} - - {id: 26, cat: Console/XBox, desc: "XboX"} - - {id: 28, cat: PC/Games, desc: "Gry PC"} - - {id: 28, cat: Console/NDS, desc: "Nintendo"} - - {id: 47, cat: Books/EBook, desc: "EEbooki"} - - {id: 48, cat: Audio/Audiobook, desc: "Audio Book"} - - {id: 50, cat: Books/EBook, desc: "Ebook Pdf"} - - {id: 67, cat: Books/Comics, desc: "Komiksy"} - - {id: 59, cat: Other, desc: "BBRG"} - - {id: 39, cat: Movies/BluRay, desc: "Filmy BluRay 50/25/9"} - - {id: 88, cat: Movies/3D, desc: "Filmi 3D"} - - {id: 108, cat: Movies/BluRay, desc: "Filmi BluRay"} - - {id: 89, cat: Movies/HD, desc: "Filmi HD x264"} - - {id: 90, cat: Movies/HD, desc: "Filmi HD DivX / XviD"} - - {id: 91, cat: Movies/DVD, desc: "Filmi DVD 5 / DVD 9"} - - {id: 92, cat: Movies/SD, desc: "Filmi SD DivX / XviD"} - - {id: 93, cat: Movies/SD, desc: "Filmi SD x264"} - - {id: 96, cat: Movies, desc: "Filmi TVRip"} - - {id: 101, cat: Movies, desc: "Filmi Boxset"} - - {id: 98, cat: Movies, desc: "Filmi Seriale"} - - {id: 95, cat: Movies, desc: "Bajki"} - - {id: 97, cat: Audio/Video, desc: "Kabarety"} - - {id: 102, cat: Movies/HD, desc: "Filmi x265"} - - {id: 104, cat: Movies, desc: "Filmi FILMY GSM / PDA"} - - {id: 86, cat: Audio, desc: "Muzyka BBRG"} - - {id: 110, cat: Movies, desc: "Filmi Biblijny"} - - {id: 114, cat: Movies/UHD, desc: "Filmi 4K-UHD"} - - {id: 115, cat: Movies, desc: "Filmi Fan BBRG"} - - {id: 112, cat: Movies, desc: "Filmi ENG"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: info_profile - type: info - label: Layout - default: "
  • Only the English Classic profile is supported.
  • Make sure to set the Torrent Listing (Lista torrentów) option in your profile to Classic (Klasyczny)
  • And set the Language (Dil) to English
  • Using the Modern theme will prevent results, and using Polski will prevent upload dates.
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules: Inactive accounts over 30 days will be warned by email about their inactivity. If within 24h a person does not log in to their account, they will lose them without the possibility of returning." - -login: - path: ?p=home&pid=1 - method: form - form: form#sls_form - submitpath: ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: div.error - - selector: :contains("-ERROR-") - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - - path: / - keywordsfilters: - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%25"] - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - # does not support imdbid search but has imdbid links in results. - error: - - selector: div.error:not(:contains("There are no results found.")) - - rows: - selector: "table#torrents_table_classic > tbody > tr:has(a[href*=\"?p=torrents&pid=10&action=download&tid=\"]){{ if .Config.freeleech }}:has(img[src$=\"/torrent_free.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="?p=torrents&pid=10&cid="] - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - details: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download&tid="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - optional: true - poster: - selector: a[href*="data/torrents/torrent_images/"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: a[rel="torrent_size"] - grabs: - selector: a[rel="times_completed"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - date_day: - # Uploaded Today at 04:01:07 by - # Uploaded Yesterday at 06:37:21 by - selector: td.torrent_name:contains("day at ") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - # Uploaded 17-04-2017 06:07:06 by - selector: td.torrent_name:not(:contains("day at ")) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm:ss" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img[src$="/torrent_free.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 day (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigcore.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigcore.yml deleted file mode 100644 index fac1873e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigcore.yml +++ /dev/null @@ -1,152 +0,0 @@ ---- -id: bigcore -name: BigCore -description: "BigCore is a HUNGARIAN Private Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://bigcore.eu/ - -caps: - categorymappings: - - {id: 48, cat: Movies/SD, desc: "CAM/Hun"} - - {id: 49, cat: Movies/SD, desc: "CAM/Eng"} - - {id: 50, cat: Movies/SD, desc: "SD/Hun"} - - {id: 51, cat: Movies/SD, desc: "SD/Eng"} - - {id: 52, cat: Movies/DVD, desc: "DVD/Hun"} - - {id: 53, cat: Movies/DVD, desc: "DVD/Eng"} - - {id: 54, cat: Movies/HD, desc: "HD/Hun"} - - {id: 55, cat: Movies/HD, desc: "HD/Eng"} - - {id: 56, cat: TV, desc: "Sorozat/Hun"} - - {id: 57, cat: TV, desc: "Sorozat/Eng"} - - {id: 58, cat: PC/Games, desc: "Játék/ISO"} - - {id: 59, cat: PC/ISO, desc: "Program/ISO"} - - {id: 60, cat: PC/Games, desc: "Játék/RIP"} - - {id: 61, cat: PC/0day, desc: "Program/RIP"} - - {id: 62, cat: Audio, desc: "Zene/Hun"} - - {id: 63, cat: Audio, desc: "Zene/Eng"} - - {id: 64, cat: Books, desc: "Könyv"} - - {id: 65, cat: PC/Mobile-Other, desc: "Mobil"} - - {id: 66, cat: XXX/x264, desc: "XXX/Video"} - - {id: 67, cat: XXX/ImageSet, desc: "XXX/Kép"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 1: title - 4: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek száma egy oldalon setting to 100 on your account profile. The default is 30. - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 myupload, 4 waiting for seed - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: tr[id^="torrent_row_"], tr[id^="hidden_torrent_row_"] - after: 1 - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a#torrentName - attribute: title - _id: - selector: a[href^="download.php?torrent="] - attribute: href - filters: - - name: querystring - args: torrent - details: - text: "details.php?id={{ .Result._id }}" - download: - selector: a[href^="download.php?torrent="] - attribute: href - poster: - selector: a[href^="kepek/"] - attribute: href - files: - selector: a.viewFileList - date: - selector: td:nth-child(7) a[title] - attribute: title - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - seeders: - selector: td:nth-child(10) - leechers: - selector: td:nth-child(11) - description: - case: - i.fa-check: "Verified" - i.fa-question: "Unverified" - downloadvolumefactor: - case: - span:contains("0x"): 0 - "*": 1 - uploadvolumefactor: - case: - span:contains("2x"): 2 - span:contains("3x"): 3 - span:contains("4x"): 4 - span:contains("5x"): 5 - "*": 1 - minimumseedtime: - # 1 days (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigfangroup.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigfangroup.yml deleted file mode 100644 index 98e78074..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bigfangroup.yml +++ /dev/null @@ -1,232 +0,0 @@ ---- -id: bigfangroup -name: BigFANGroup -description: "BigFANGroup is a RUSSIAN Public Torrent Tracker for MOVIES / TV" -language: ru-RU -type: public -encoding: windows-1251 -links: - - https://bigfangroup.org/ - - https://www.freebfg.org/ - -caps: - categorymappings: - - {id: 51, cat: Movies/3D, desc: "3D"} - - {id: 12, cat: TV, desc: "Анимация"} - - {id: 35, cat: Audio/Audiobook, desc: "Аудио-книги"} - - {id: 13, cat: Movies, desc: "Боевик"} - - {id: 52, cat: Movies, desc: "Биография"} - - {id: 33, cat: Movies, desc: "Военный"} - - {id: 45, cat: Audio/Video, desc: "Видеоклипы"} - - {id: 48, cat: Movies, desc: "Вестерн"} - - {id: 21, cat: Movies, desc: "Драма"} - - {id: 32, cat: TV/Documentary, desc: "Документальный"} - - {id: 39, cat: Movies, desc: "Детектив"} - - {id: 44, cat: Audio, desc: "Зарубежная музыка"} - - {id: 5, cat: PC/Games, desc: "Игры"} - - {id: 28, cat: Movies, desc: "Исторический"} - - {id: 18, cat: Movies, desc: "Катастрофа"} - - {id: 24, cat: Movies, desc: "Комедия"} - - {id: 36, cat: Movies, desc: "Криминал"} - - {id: 38, cat: Books, desc: "Книги и журналы"} - - {id: 53, cat: Movies, desc: "Классика"} - - {id: 54, cat: Audio, desc: "Классическая музыка"} - - {id: 19, cat: Movies, desc: "Мелодрама"} - - {id: 20, cat: Movies, desc: "Мультфильм"} - - {id: 31, cat: Movies, desc: "Мистика"} - - {id: 46, cat: Audio, desc: "Муз. сборники"} - - {id: 29, cat: Movies/Foreign, desc: "Наше кино"} - - {id: 49, cat: TV, desc: "Научно-популярное"} - - {id: 50, cat: TV, desc: "Новости"} - - {id: 27, cat: Movies, desc: "Приключения"} - - {id: 9, cat: Other, desc: "Разное"} - - {id: 43, cat: Audio/Foreign, desc: "Русская музыка"} - - {id: 1, cat: PC, desc: "Софт"} - - {id: 11, cat: TV, desc: "Сериалы"} - - {id: 37, cat: TV/Sport, desc: "Спорт"} - - {id: 47, cat: Movies, desc: "Сказки"} - - {id: 55, cat: Audio, desc: "Саундтрек"} - - {id: 25, cat: TV, desc: "ТВ-шоу"} - - {id: 22, cat: Movies, desc: "Триллер"} - - {id: 26, cat: Movies, desc: "Ужасы"} - - {id: 23, cat: Movies, desc: "Фантастика"} - - {id: 30, cat: Movies, desc: "Фэнтази"} - - {id: 42, cat: XXX, desc: "Эротика"} - - {id: 14, cat: Movies/HD, desc: "HDTV"} - - {id: 15, cat: Movies/DVD, desc: "DVD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seed: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -search: - paths: - # https://bigfangroup.org/browse.php?ajax=1&search=the+librarian&cat=0&incldead=0&year=0&format=0 - - path: browse.php - inputs: - ajax: 1 - search: "{{ .Keywords }}" - cat: "{{ if .Keywords }}0{{ else }}{{ end }}" - # 0 active, 1 incldead, 2 onlydead, 3 gold, 4 noseed, 5 silver, 7 BFG - incldead: "{{ if .Keywords }}1{{ else }}{{ end }}" - year: "{{ if .Keywords }}0{{ else }}1{{ end }}" - format: "{{ if .Keywords }}0{{ else }}{{ end }}" - s: "{{ if .Keywords }}{{ .Config.sort }}{{ else }}{{ end }}" - d: "{{ if .Keywords }}{{ .Config.type }}{{ else }}{{ end }}" - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: table > tbody#highlighted > tr:has(a[href^="browse.php?cat="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(?i)\\s\\|\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details", "download"] - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - date: - selector: img[src="pic/time.png"] - attribute: title - filters: - - name: replace - args: [" в ", " "] - - name: replace - args: ["января", "January"] - - name: replace - args: ["февраля", "February"] - - name: replace - args: ["марта", "March"] - - name: replace - args: ["апреля", "April"] - - name: replace - args: ["мая", "May"] - - name: replace - args: ["июня", "June"] - - name: replace - args: ["июля", "July"] - - name: replace - args: ["августа", "August"] - - name: replace - args: ["сентября", "September"] - - name: replace - args: ["октября", "October"] - - name: replace - args: ["ноября", "November"] - - name: replace - args: ["декабря", "December"] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "d MMMM yyyy HH:mm:ss zzz" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="details.php?id="] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitbazis.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitbazis.yml deleted file mode 100644 index 1e449726..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitbazis.yml +++ /dev/null @@ -1,184 +0,0 @@ ---- -id: bitbazis -name: Bit-Bázis -description: "Bit-Bázis is a HUNGARIAN Private Tracker for MOVIES / TV" -language: hu-HU -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://bitbazis.net/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 15, cat: Movies/SD, desc: "Film-SD", default: true} - - {id: 83, cat: Movies/HD, desc: "Film-HD", default: true} - - {id: 94, cat: Movies/HD, desc: "Film-FHD", default: true} - - {id: 85, cat: Movies/SD, desc: "Film-Cam", default: true} - - {id: 22, cat: TV, desc: "Sorozat-Hun", default: true} - - {id: 25, cat: Audio/MP3, desc: "Mp3-Hun", default: true} - - {id: 26, cat: Audio/MP3, desc: "Mp3-Eng", default: true} - - {id: 84, cat: Audio/Lossless, desc: "FLAC-Hun", default: true} - - {id: 71, cat: Audio/Lossless, desc: "FLAC-Eng", default: true} - - {id: 27, cat: Books/EBook, desc: "Könyv", default: true} - - {id: 81, cat: PC/0day, desc: "Program", default: true} - - {id: 31, cat: PC/Games, desc: "Játék", default: true} - - {id: 69, cat: TV/Anime, desc: "Mese-SD", default: true} - - {id: 91, cat: TV/Anime, desc: "Mese-HD", default: true} - - {id: 64, cat: Movies, desc: "Film-Pack", default: true} - - {id: 92, cat: Movies/UHD, desc: "Film-UHD", default: true} - - {id: 66, cat: XXX/ImageSet, desc: "XXX", default: false} - - {id: 33, cat: XXX/SD, desc: "XXX-SD", default: false} - - {id: 89, cat: XXX/x264, desc: "XXX-HD", default: false} - - {id: 24, cat: Audio/Video, desc: "Klip", default: true} - - {id: 14, cat: Other, desc: "Egyéb", default: true} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 1: title - 4: added - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek száma egy oldalon: setting to 50 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "If the user does not access the site for 1 month, he will receive an e-mail notification. If you stay away for 9 months, your account will be suspended and deleted. After the 9-month absence, the user cannot register again." - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[onclick="confirmLogout()"] - -search: - paths: - - path: browse.php - categories: [15, 83, 94, 85, 22, 25, 26, 84, 71, 27, 81, 31, 69, 91, 64, 92, 24, 14] - inputs: - # 0 all, 1 active, 2 deadonly, 3 myuploads, 4 zeroseed, 6 re-seeded - incldead: 0 - - path: browse.php - categories: [33, 66, 89] - inputs: - xxx: 1 - # 0 active, 1 all, 2 deadonly, 3 myuploads, 4 zeroseed, 6 re-seeded - incldead: 1 - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # site does not support imdbid searching - # can support genre searching but you need to know the id, eg &genre23=1 for Comedy (id is 23) - - rows: - selector: "tr[class^=\"toriadatok\"]:has(i.status-yes){{ if .Config.freeleech }}:has(i:contains(\"(Free)\")){{ else }}{{ end }}" - - fields: - categorydesc: - selector: td[class^="catpic_"] - attribute: class - filters: - - name: replace - args: ["catpic_", ""] - title: - selector: span.torrent-name - _id: - selector: tr[data-torrent-id] - attribute: data-torrent-id - details: - text: "details.php?id={{ .Result._id }}#adatlap" - download: - text: "download.php?torrent={{ .Result._id }}" - poster: - selector: a.preview - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: td.torrent-size - date: - # 36 perce - # 13 órája és 59 perce - # 2 napja és 10 órája - # 1 hete és 1 napja - # 1 éve és 30 hete - selector: span.uploader - filters: - - name: replace - args: ["és ", " "] - - name: replace - args: ["perce", "minute"] - - name: replace - args: ["órája", "hour"] - - name: replace - args: ["napja", "day"] - - name: replace - args: ["hete", "week"] - - name: replace - args: ["éve", "year"] - - name: timeago - seeders: - selector: td:nth-child(5) - optional: true - default: 0 - leechers: - selector: td:nth-child(6) - optional: true - default: 0 - grabs: - selector: td:nth-child(7) - optional: true - default: 0 - genre: - selector: span.torrent-genre - description: - text: "{{ .Result.genre }}" - downloadvolumefactor: - case: - "i:contains(\"(Free)\")": 0 - "*": 1 - uploadvolumefactor: - case: - "i:contains(\"(2x)\")": 2 - "i:contains(\"(3x)\")": 3 - "i:contains(\"(4x)\")": 4 - "*": 1 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitded.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitded.yml deleted file mode 100644 index 7e58d008..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitded.yml +++ /dev/null @@ -1,146 +0,0 @@ ---- -id: bitded -name: Bitded -description: "Bitded is a THAI Private Torrent Tracker for MOVIES / TV / GENERAL" -language: th-TH -type: private -encoding: UTF-8 -links: - - https://www.dedbit.com/ - -caps: - categorymappings: - - {id: 1, cat: Other, desc: "ในพระราชสำนัก (in the royal court)"} - - {id: 4, cat: TV, desc: "รายการทีวี (TV)"} - - {id: 7, cat: TV, desc: "ภาพยนตร์ชุด (Series)"} - - {id: 12, cat: Books/EBook, desc: "หนังสือ สื่อการสอน (Books)"} - - {id: 13, cat: Other/Misc, desc: "ทั่วไป (General)"} - - {id: 14, cat: TV/Sport, desc: "กีฬา (Sport)"} - - {id: 16, cat: TV/Documentary, desc: "สารคดี (Documentary)"} - - {id: 18, cat: TV/Other, desc: "ธรรมะ (Dharma)"} - - {id: 21, cat: Books/Comics, desc: "หนังสือการ์ตูน (Comics)"} - - {id: 22, cat: TV/Anime, desc: "การ์ตูนอนิเมชั่น (Anime)"} - - {id: 31, cat: Movies, desc: "ภาพยนตร์ (Movies)"} - - {id: 34, cat: Movies/DVD, desc: "ภาพยนตร์ DVD (Movies DVD)"} - - {id: 35, cat: Movies/HD, desc: "วีดีโอความละเอียดสูง (Movies HD)"} - - {id: 41, cat: Audio/MP3, desc: "เพลง (Music)"} - - {id: 43, cat: Audio/Video, desc: "มิวสิควีดีโอ (Music video)"} - - {id: 53, cat: PC/0day, desc: "โปรแกรม/Windows (Software Windows)"} - - {id: 54, cat: PC/Mac, desc: "โปรแกรม/Mac, Linux (Software Mac/Linux)"} - - {id: 55, cat: PC/Mobile-Other, desc: "โปรแกรม/Mobile (Software Mobile)"} - - {id: 61, cat: PC/Games, desc: "เกม-Computer (PC Games)"} - - {id: 62, cat: Console, desc: "เกม-Console (Console)"} - - {id: 63, cat: Console, desc: "เกมส์-พกพา (Console portable)"} - - {id: 72, cat: Other, desc: "รูปภาพ (Pics)"} - - {id: 74, cat: Other, desc: "J-IDOL"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: takelogin.php - method: post - inputs: - x: axion - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:contains("Login failed!") - test: - path: my.php - selector: a[href="logout.php"] - -search: - paths: - # https://www.dedbit.com/browse.php?c12=1&c13=1&incldead=1&freeload=1&search=&sort=added&h=6&d=DESC - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - freeload: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - # site does not support imdbid searching and does not display imdb links in results. - - rows: - selector: table.torrenttable tbody tr:has(a[href^="download.php"]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - files: - selector: td:nth-last-child(9) - date: - selector: td:nth-last-child(7) - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - img[src$="pic/freeload.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitgamer.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitgamer.yml deleted file mode 100644 index 350a9bed..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitgamer.yml +++ /dev/null @@ -1,161 +0,0 @@ ---- -id: bitgamer -name: bitGAMER -description: "bitGAMER is a Private Torrent Tracker for GAMES" -language: en-US -type: private -encoding: UTF-8 -links: - - https://bitgamer.ch/ - -caps: - categorymappings: - - {id: 1, cat: XXX/Other, desc: "Adult Games"} - - {id: 3, cat: PC/0day, desc: "Burning/Ripping Tools"} - - {id: 4, cat: Books/Other, desc: "Guides/Game Documents"} - - {id: 13, cat: Movies/Other, desc: "Movies/TV/Video"} - - {id: 48, cat: Audio/Other, desc: "Music/OST"} - - {id: 52, cat: Console/Other, desc: "Amiga"} - - {id: 18, cat: PC/Mobile-iOS, desc: "Apple - iPhone/iPod/iPad"} - - {id: 54, cat: Console/Other, desc: "Arcade"} - - {id: 19, cat: Console/Other, desc: "Atari"} - - {id: 51, cat: Console/Other, desc: "Commodore 64"} - - {id: 22, cat: PC/Mobile-Android, desc: "Google - Android"} - - {id: 61, cat: Console/Other, desc: "Hyperspin"} - - {id: 24, cat: Console/XBox, desc: "Microsoft - Xbox"} - - {id: 25, cat: Console/XBox 360, desc: "Microsoft - Xbox 360"} - - {id: 49, cat: Console/XBox 360, desc: "Microsoft - Xbox 360 JTAG"} - - {id: 57, cat: Console/XBox One, desc: "Microsoft - Xbox One"} - - {id: 27, cat: Console/Other, desc: "Neo Geo"} - - {id: 55, cat: Console/3DS, desc: "Nintendo - 3DS"} - - {id: 28, cat: Console/NDS, desc: "Nintendo - DS"} - - {id: 29, cat: Console/Other, desc: "Nintendo - Game Boy Advance"} - - {id: 30, cat: Console/Other, desc: "Nintendo - Gameboy"} - - {id: 31, cat: Console/Other, desc: "Nintendo - Gamecube "} - - {id: 33, cat: Console/Other, desc: "Nintendo - N64 "} - - {id: 35, cat: Console/Other, desc: "Nintendo - NES"} - - {id: 50, cat: Console/Other, desc: "Nintendo - SNES"} - - {id: 63, cat: Console/Other, desc: "Nintendo - Switch"} - - {id: 37, cat: Console/Wii, desc: "Nintendo - Wii"} - - {id: 59, cat: Console/WiiU, desc: "Nintendo - Wii U"} - - {id: 53, cat: PC/Games, desc: "PC - DOS"} - - {id: 64, cat: PC/Games, desc: "PC - Linux"} - - {id: 39, cat: PC/Mac, desc: "PC - Mac OS X"} - - {id: 40, cat: PC/Games, desc: "PC - Windows"} - - {id: 41, cat: Console/Other, desc: "Sega"} - - {id: 42, cat: Console/Other, desc: "Sega - Dreamcast"} - - {id: 43, cat: Console/Other, desc: "Sega - Megadrive"} - - {id: 44, cat: Console/Other, desc: "Sony - Playstation"} - - {id: 45, cat: Console/Other, desc: "Sony - Playstation 2"} - - {id: 46, cat: Console/PS3, desc: "Sony - Playstation 3"} - - {id: 56, cat: Console/PS4, desc: "Sony - Playstation 4"} - - {id: 47, cat: Console/PSP, desc: "Sony - Playstation Portable "} - - {id: 58, cat: Console/PS Vita, desc: "Sony - Playstation Vita"} - - modes: - search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td:has(:contains("Login failed")) - test: - path: my.php - selector: a[href^="logout.php"] - -search: - paths: - - path: browse.php - inputs: - search: "{{ .Keywords }}" - # multi-cat search appears to be broken on site - category: 0 - # 0: name, 1: descr, 2: both - blah: 0 - # 0: include dead, 1: alive, 2: dead, 3: freeleech - incldead: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: .outer > form > table > tbody > tr:has(a[href^="download.php"]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php"][title] - attribute: title - details: - selector: a[href^="details.php"] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - date: - # several old torrents with `0000-00-00 00:00:00` dates - selector: td:nth-child(5):not(:contains("000-00-0000:00:00")) - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - optional: true - default: now - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(10) - leechers: - selector: td:nth-child(11) - grabs: - selector: td:nth-child(7) - downloadvolumefactor: - case: - img[src*="/freeleech.png"]: 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bithumen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bithumen.yml deleted file mode 100644 index bb17fcaa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bithumen.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: bithumen -name: BitHUmen -description: "BitHUmen is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: ISO-8859-2 -links: - - https://bithumen.be/ - -caps: - categorymappings: - - {id: 23, cat: Movies/SD, desc: "Film/Hun/SD"} - - {id: 24, cat: Movies/DVD, desc: "Film/Hun/DVD-R"} - - {id: 25, cat: Movies/HD, desc: "Film/Hun/720p"} - - {id: 37, cat: Movies/HD, desc: "Film/Hun/1080p"} - - {id: 33, cat: Movies/BluRay, desc: "Film/Hun/Blu-ray"} - - {id: 30, cat: XXX, desc: "XXX/SD"} - - {id: 19, cat: Movies/SD, desc: "Film/Eng/SD"} - - {id: 20, cat: Movies/DVD, desc: "Film/Eng/DVD-R"} - - {id: 5, cat: Movies/HD, desc: "Film/Eng/720p"} - - {id: 39, cat: Movies/HD, desc: "Film/Eng/1080p"} - - {id: 40, cat: Movies/BluRay, desc: "Film/Eng/Blu-ray"} - - {id: 34, cat: XXX, desc: "XXX/HD"} - - {id: 7, cat: TV/SD, desc: "Sorozat/Hun/SD"} - - {id: 41, cat: TV/HD, desc: "Sorozat/Hun/HD"} - - {id: 26, cat: TV/SD, desc: "Sorozat/Eng/SD"} - - {id: 42, cat: TV/HD, desc: "Sorozat/Eng/HD"} - - {id: 28, cat: Books, desc: "eBook/Hun"} - - {id: 29, cat: Books, desc: "eBook/Eng"} - - {id: 9, cat: Audio/MP3, desc: "Mp3/Hun"} - - {id: 35, cat: Audio/Lossless, desc: "Lossless/Hun"} - - {id: 1, cat: PC/0day, desc: "Programok/ISO"} - - {id: 4, cat: PC/Games, desc: "Játékok/ISO"} - - {id: 31, cat: Console/PS4, desc: "Játékok/PS"} - - {id: 36, cat: Console/Wii, desc: "Játékok/Wii"} - - {id: 6, cat: Audio/MP3, desc: "Mp3/Eng"} - - {id: 38, cat: Audio/Lossless, desc: "Lossless/Eng"} - - {id: 22, cat: PC, desc: "Programok/egyéb"} - - {id: 21, cat: PC, desc: "Játékok/Rip/Dox"} - - {id: 32, cat: Console/XBox 360, desc: "Játékok/Xbox360"} - - {id: 27, cat: Other, desc: "Klipek"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If you do not visit the site for 1 year, you will be banned for inactivity. The permanent deletion of your account after the ban is 2 years." - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="/logout.php?"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - onlytitle: "{{ if .Query.IMDBID }}{{ else }}yes{{ end }}" - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table#torrenttable > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - # download button can be disabled in the profile, use details link instead - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php?id=", "download.php/"] - - name: append - args: "/invalid.torrent" - poster: - selector: a[onmouseover^="bithumen.UI.images.coverShow"] - attribute: onmouseover - filters: - - name: regexp - args: "\"(.*?)\"" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: td:nth-child(6) > u - files: - selector: td:nth-child(3) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - filters: - - name: regexp - args: /\s*([\d,]+) - genre: - selector: span:has(a[href^="browse.php?genre="]) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - selector: td:nth-child(5) > nobr > font:contains(" × ") - optional: true - default: 1 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td:nth-child(2) > div - date_year: - selector: td:nth-child(5):contains('.') - optional: true - remove: font - filters: - - name: replace - args: [". ", " "] - - name: prepend - args: "{{ .Today.Year }}." - - name: re_replace - args: ["([0-9]{4}).([0-9]+).([0-9]+) (.*)", "$2.$3.$1 $4"] - date_day: - selector: td:nth-child(5):contains('ma'), td:nth-child(5):contains("tegnap"), td:nth-child(5):contains('-') - optional: true - remove: font - filters: - - name: replace - args: ["ma", "today"] - - name: replace - args: ["tegnap", "yesterday"] - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitmagnet.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitmagnet.yml deleted file mode 100644 index 79dfb77c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitmagnet.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -id: bitmagnet -name: BitMagnet (Local DHT) -description: "BitMagnet is a self-hosted BitTorrent DHT search engine" -language: en-US -type: semi-private -encoding: UTF-8 -links: - - http://127.0.0.1:3333/ - - http://bitmagnet:3333/ - -caps: - categorymappings: - - {id: 2000, cat: Movies, desc: "movie"} - - {id: 2030, cat: Movies/SD, desc: "movie_sd"} # unverified - - {id: 2040, cat: Movies/HD, desc: "movie_hd"} # unverified - - {id: 2045, cat: Movies/UHD, desc: "movie_uhd"} # unverified - - {id: 2060, cat: Movies/3D, desc: "movie_3d"} # unverified - - {id: 3000, cat: Audio, desc: "music"} - - {id: 3030, cat: Audio/Audiobook, desc: "audiobook"} - - {id: 4000, cat: PC, desc: "software"} - - {id: 4050, cat: PC/Games, desc: "game"} # unverified - - {id: 5000, cat: TV, desc: "tv_show"} - - {id: 5030, cat: TV/SD, desc: "tv_show_sd"} # unverified - - {id: 5040, cat: TV/HD, desc: "tv_show_hd"} # unverified - - {id: 5045, cat: TV/UHD, desc: "tv_show_uhd"} # unverified - - {id: 6000, cat: XXX, desc: "xxx"} - - {id: 6070, cat: XXX/Other, desc: "xxx_other"} # unverified - - {id: 7000, cat: Books, desc: "book"} # unverified - - {id: 7020, cat: Books/EBook, desc: "ebook"} - - {id: 7030, cat: Books/Comics, desc: "comic"} - - {id: 8000, cat: Other, desc: "Unknown"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: [] - -search: - paths: - - path: torznab/api - response: - type: xml - inputs: - t: "{{ .Query.Type }}" - q: "{{ .Keywords }}" - cat: "{{ join .Categories \",\" }}" - imdbid: "{{ .Query.IMDBIDShort }}" - tmdbid: "{{ .Query.TMDBID }}" - season: "{{ .Query.Season }}" - ep: "{{ .Query.Ep }}" - limit: 100 - - rows: - selector: rss > channel > item - - fields: - categorydesc: - selector: category - title: - selector: title - details: - text: / - date: - selector: pubDate - infohash: - selector: guid - size: - selector: size - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitpalace.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitpalace.yml deleted file mode 100644 index 46b6d0ba..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitpalace.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: bitpalace -name: Bitpalace -description: "Bitpalace is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -certificates: - - 1453f04660cdfb424857a8ec01936ae026945569 # using cert for .hu domain -links: - - https://bitpalace.cc/ -legacylinks: - - https://bitpalace.hu/ - -caps: - categorymappings: - - {id: 8, cat: Movies/BluRay, desc: "BLUERAY ENG"} - - {id: 7, cat: Movies/BluRay, desc: "BLUERAY HUN"} - - {id: 15, cat: Movies/DVD, desc: "DVD-R ENG"} - - {id: 2, cat: Movies/DVD, desc: "DVD-R HUN"} - - {id: 9, cat: Books, desc: "Ebook ENG"} - - {id: 5, cat: Books, desc: "Ebook HUN"} - - {id: 40, cat: Other, desc: "EGYÉBB"} - - {id: 16, cat: Movies/HD, desc: "HD ENG"} - - {id: 1, cat: Movies/HD, desc: "HD HUN"} - - {id: 25, cat: TV/Anime, desc: "MESE ENG"} - - {id: 24, cat: TV/Anime, desc: "MESE HUN"} - - {id: 18, cat: PC/Games, desc: "PC game ISO"} - - {id: 21, cat: Console, desc: "PC game KONZOL"} - - {id: 17, cat: PC/Games, desc: "PC game RIP"} - - {id: 27, cat: PC/ISO, desc: "Program ISO"} - - {id: 26, cat: PC/Mobile-Other, desc: "Program MOBIL"} - - {id: 78, cat: PC/0day, desc: "Program RIP"} - - {id: 23, cat: TV/SD, desc: "Sor.DVD-R ENG"} - - {id: 22, cat: TV/SD, desc: "Sor.DVD-R HUN"} - - {id: 14, cat: TV/SD, desc: "Sor.XVID ENG"} - - {id: 4, cat: TV, desc: "Sorozat HUN"} - - {id: 13, cat: Movies/SD, desc: "XVID ENG"} - - {id: 3, cat: Movies/SD, desc: "XVID HUN"} - - {id: 12, cat: XXX/DVD, desc: "XXX DVD-R"} - - {id: 6, cat: XXX/x264, desc: "XXX HD"} - - {id: 10, cat: XXX/ImageSet, desc: "XXX KÉPEK"} - - {id: 11, cat: XXX/XviD, desc: "XXX XVID"} - - {id: 20, cat: Audio, desc: "ZENE Eng"} - - {id: 19, cat: Audio, desc: "ZENE Hun"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login - method: form - form: form[action="/login_check"] - inputs: - _username: "{{ .Config.username }}" - _password: "{{ .Config.password }}" - logout: "" - error: - - selector: h3[style="color:red"] - test: - path: my - selector: a[href="/logout"] - -search: - paths: - - path: browse - keywordsfilters: - # the site uses % for wildcard - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%"] - inputs: - $raw: "{{ range .Categories }}cat[]={{.}}&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # does not support imdbid searches - - rows: - selector: "table#torrenttable > tbody > tr:has(a[onclick]):not(:contains(\"Törölt\")){{ if .Config.freeleech }}:has(img[src$=\"/freeicon.png\"]){{ else }}{{ end }}" - - fields: - categorydesc: - selector: img - attribute: alt - title: - selector: a[onclick] - remove: style - _id: - selector: a[onclick] - attribute: onclick - filters: - - name: regexp - args: (\d+) - details: - text: "details?id={{ .Result._id }}" - download: - text: "/raw/download?id={{ .Result._id }}&name={{ .Result.title }}.torrent" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - description: - case: - img[src$="/yes.png"]: "Verified" - img[src$="/nincs.png"]: "Unverified" - poster: - selector: img[width="200"][height="250"] - attribute: src - date: - selector: td:nth-last-child(6) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - img[src$="/freeicon.png"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src$="/2xup.jpg"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitporn.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitporn.yml deleted file mode 100644 index 97c08959..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitporn.yml +++ /dev/null @@ -1,195 +0,0 @@ ---- -id: bitporn -name: BitPorn (API) -description: "BitPorn (Malacka) is a HUNGARIAN Private Torrent Tracker for 3X" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://bitporn.eu/ - -caps: - categorymappings: - - {id: 54, cat: XXX, desc: Ai Generated"} - - {id: 4, cat: XXX, desc: Amateur"} - - {id: 5, cat: XXX, desc: Anal"} - - {id: 6, cat: XXX, desc: Asian"} - - {id: 7, cat: XXX, desc: BBW"} - - {id: 8, cat: XXX, desc: BDSM"} - - {id: 9, cat: XXX, desc: Big Ass"} - - {id: 10, cat: XXX, desc: Big Tits"} - - {id: 11, cat: XXX, desc: Black"} - - {id: 12, cat: XXX, desc: Cartoon"} - - {id: 13, cat: XXX, desc: Casting"} - - {id: 14, cat: XXX, desc: Classic"} - - {id: 15, cat: XXX, desc: Collection"} - - {id: 16, cat: XXX, desc: Creampie"} - - {id: 17, cat: XXX, desc: Cumshot"} - - {id: 18, cat: XXX, desc: Deepthroat"} - - {id: 19, cat: XXX, desc: Extreme"} - - {id: 20, cat: XXX, desc: Fansite"} - - {id: 21, cat: XXX, desc: Family"} - - {id: 22, cat: XXX, desc: Feature"} - - {id: 23, cat: XXX, desc: Fetish"} - - {id: 24, cat: XXX, desc: Fisting"} - - {id: 25, cat: XXX, desc: Gangbang"} - - {id: 26, cat: XXX, desc: Game"} - - {id: 27, cat: XXX, desc: Gay / Bi"} - - {id: 28, cat: XXX, desc: Hair"} - - {id: 29, cat: XXX, desc: Hardcore"} - - {id: 30, cat: XXX, desc: HiddenCam"} - - {id: 31, cat: XXX, desc: Homemade"} - - {id: 32, cat: XXX, desc: Interracial"} - - {id: 33, cat: XXX, desc: Lesbian"} - - {id: 34, cat: XXX, desc: Magyar"} - - {id: 35, cat: XXX, desc: Masturbation"} - - {id: 36, cat: XXX, desc: Mature"} - - {id: 37, cat: XXX, desc: Milf"} - - {id: 53, cat: XXX, desc: Movie"} - - {id: 38, cat: XXX, desc: Old and Young"} - - {id: 39, cat: XXX, desc: Parody"} - - {id: 40, cat: XXX, desc: Pictures"} - - {id: 41, cat: XXX, desc: Pissing"} - - {id: 42, cat: XXX, desc: POV"} - - {id: 43, cat: XXX, desc: Pregnant"} - - {id: 44, cat: XXX, desc: Public"} - - {id: 45, cat: XXX, desc: Shemale"} - - {id: 46, cat: XXX, desc: Softcore"} - - {id: 47, cat: XXX, desc: Squirt"} - - {id: 48, cat: XXX, desc: Straight"} - - {id: 49, cat: XXX, desc: Teen"} - - {id: 50, cat: XXX, desc: Threesome"} - - {id: 51, cat: XXX, desc: VR"} - - {id: 52, cat: XXX, desc: Uncategorized"} - - modes: - search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your BitPorn account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in to the site at least once a month; otherwise, your account will be suspended." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title: - selector: name - details: - selector: details_link - download: - selector: download_link - files: - selector: num_file - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 48 hours (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitru.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitru.yml deleted file mode 100644 index 943ea748..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitru.yml +++ /dev/null @@ -1,214 +0,0 @@ ---- -id: bitru -name: BitRu -description: "BitRu is a RUSSIAN Public Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://bitru.org/ -legacylinks: - - http://bitru.org/ # site forces https - -caps: - categorymappings: - - {id: movie, cat: Movies, desc: Movie} - - {id: serial, cat: TV, desc: Serial} - - {id: music, cat: Audio, desc: Music} - - {id: game, cat: PC/Games, desc: Game} - - {id: soft, cat: PC, desc: Software} - - {id: literature, cat: Books, desc: Literature} - - {id: audiobook, cat: Audio/Audiobook, desc: Audiobook} - - {id: video, cat: Movies, desc: Movies} - - {id: image, cat: Other, desc: Image} - - {id: xxx, cat: XXX, desc: xxx} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: adverts - type: checkbox - label: Include Advertising - default: true - - name: sort - type: select - label: Sort requested from site - default: _ - options: - _: created - seeders: seeders - size: size - -search: - paths: - - path: browse.php - inputs: - s: "{{ .Keywords }}" - sort: "{{ re_replace .Config.sort \"_\" \"\" }}" - rek: "{{ if .Config.adverts }}{{ else }}no{{ end }}" - - keywordsfilters: - - name: re_replace # S01 or S01E01 to 1 сезон - args: ["(?i)\\bS0*(\\d+)(?:E0*(\\d+))?\\b", "$1 сезон"] - - rows: - selector: table.browse-list > tbody > tr - - fields: - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: querystring - args: tmp - title: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?\\s*[:]*.+?\\((\\d+(?:-\\d+)?)\\s*из\\s*(\\d+)\\)", "(S$1E$2 of $3)"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?\\s*[:]*.+?\\((\\d+(?:-\\d+)?)\\)", "(S$1E$2)"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "(S$1)"] - - name: re_replace - args: ["(?i)\\((\\d+(?:-\\d+)?)\\s*из\\s*(\\d+)\\)", "(E$1 of $2)"] - - name: re_replace - args: ["(?i)\\bселезень\\b", "selezen"] - - name: re_replace - args: ["(?i)\\bFiles-х\\b", "Files-x"] - - name: re_replace - args: ["(?i)\\s\\|\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)(-[\\w\\p{P}\\p{S}]*)$", "$2 $1$3"] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details", "download"] - description: - selector: i.fa-ad - attribute: title - filters: - - name: replace - args: ["Присутствует реклама", "[Contains Adverts] "] - description|append: - selector: a[href^="details.php?id="] - poster: - selector: div.it-image > img - attribute: src - date_today: - # Сегодня в 09:09 от - # Вчера в 22:47 от - selector: td:nth-child(2) div.b-info div span:nth-child(1):contains("Сегодня"), td:nth-child(2) div.b-info div span:nth-child(1):contains("Вчера") - optional: true - filters: - - name: regexp - args: "(.*?) от " - - name: replace - args: ["Сегодня в", "Today"] - - name: replace - args: ["Вчера в", "Yesterday"] - date_year: - # 08 ноября 2019 в 23:53 от - selector: td:nth-child(2) div.b-info div span:nth-child(1):not(:contains("Сегодня")):not(:contains("Вчера")) - optional: true - filters: - - name: regexp - args: "(.*?) от " - - name: replace - args: [" в ", " "] - - name: replace - args: ["января", "January"] - - name: replace - args: ["февраля", "February"] - - name: replace - args: ["марта", "March"] - - name: replace - args: ["апреля", "April"] - - name: replace - args: ["мая", "May"] - - name: replace - args: ["июня", "June"] - - name: replace - args: ["июля", "July"] - - name: replace - args: ["августа", "August"] - - name: replace - args: ["сентября", "September"] - - name: replace - args: ["октября", "October"] - - name: replace - args: ["ноября", "November"] - - name: replace - args: ["декабря", "December"] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "dd MMMM yyyy HH:mm zzz" - date: - text: "{{ if or .Result.date_today .Result.date_year }}{{ or .Result.date_today .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(3) - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: td:nth-child(4) - filters: - - name: replace - args: ["?", "1"] - leechers: - selector: td:nth-child(5) - filters: - - name: replace - args: ["?", "0"] - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitsearch.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitsearch.yml deleted file mode 100644 index eef56def..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitsearch.yml +++ /dev/null @@ -1,147 +0,0 @@ ---- -id: bitsearch -replaces: - - solidtorrents -name: BitSearch -description: "BitSearch (Solid Torrents) is a Public torrent meta-search engine" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 3 -links: - - https://bitsearch.to/ - - https://solidtorrents.eu/ -legacylinks: - - https://bitsearch.nocensor.cloud/ - - https://bitsearch.mrunblock.bond/ - - https://solidtorrents.net/ - - https://solidtorrents.nocensor.cloud/ - - https://solidtorrents.to/ - -caps: - categories: - Anime: TV/Anime - Anime/Dub/Dual Audio: TV/Anime - Anime/Raw: TV/Anime - Anime/Subbed: TV/Anime - AudioBook: Audio/Audiobook - Books: Books - Books/Academic: Books/EBook - Books/Comics: Books/Comics - Books/Fiction: Books/EBook - Books/Magazines: Books/Mags - Books/Non-fiction: Books/EBook - Ebook/Course: Books/EBook - Games: Console - Games/Console: Console - Games/Mobile: PC/Mobile-Other - Games/PC: PC/Games - Games/RIP: PC/Games - Movies: Movies - Movies/Dub/Dual Audio: Movies - Music: Audio - Music/Album: Audio - Music/Discography: Audio - Music/FLAC: Audio/Lossless - Music/Lossless: Audio/Lossless - Music/MP3: Audio/MP3 - Music/Single: Audio - Other: Other - Other/Android: PC/Mobile-Android - Other/Archive: Other - Other/Audio: Audio - Other/Database: PC - Other/DiskImage: PC/ISO - Other/Document: Books/Comics - Other/Image: Other/Misc - Other/Program: PC - Other/Source Code: PC - Other/Video: TV - Softwares: PC/0day - Softwares/Android: PC/Mobile-Android - Softwares/Linux: PC - Softwares/MacOS: PC/Mac - Softwares/Windows: PC/0day - Softwares/iOS: PC/Mobile-iOS - TV: TV - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site. - default: created - options: - created: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site. - default: desc - options: - desc: desc - asc: asc - -download: - selectors: - - selector: a[href^="magnet:?xt"] - attribute: href - -search: - paths: - # https://bitsearch.to/search?q=spongebob&sortBy=created&order=desc&page=1 - - path: "{{ if .Keywords }}search?q={{ .Keywords }}&sortBy={{ .Config.sort }}&order={{ .Config.type }}&page=1{{ else }}latest{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: div.space-y-4 > div.bg-white > div.items-start - - fields: - category: - # latest, search - selector: div.space-y-2 > span:nth-child(1), span.space-x-1:nth-child(1) - title: - selector: h3 > a - details: - selector: h3 > a - attribute: href - download: - selector: h3 > a - attribute: href - size: - selector: div.space-y-2 > span:nth-child(2), span.space-x-1:nth-child(2) - date_year: - selector: div.space-y-2 > span:nth-child(3):contains("/"), span.space-x-1:nth-child(3):contains("/") - optional: true - filters: - - name: dateparse - args: "M/d/yyyy" - date_ago: - selector: div.space-y-2 > span:nth-child(3):contains("ago"), span.space-x-1:nth-child(3):contains("ago") - optional: true - filters: - - name: timeago - date: - text: "{{ if or .Result.date_year .Result.date_ago }}{{ or .Result.date_year .Result.date_ago }}{{ else }}now{{ end }}" - seeders: - selector: div.space-x-3 > span:nth-child(1), span:contains("seeders") - leechers: - selector: div.space-x-3 > span:nth-child(2), span:contains("leechers") - grabs: - selector: span:contains("downloads") - optional: true - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitturk.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitturk.yml deleted file mode 100644 index b71ea8f6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bitturk.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: bitturk -name: BiTTuRK -description: "BiTTuRK is a TURKISH Private Torrent Tracker for HD MOVIES / TV / GENERAL. This Indexer is for English only." -language: en-US -type: private -encoding: UTF-8 -links: - - https://bitturk.net/ -legacylinks: - - http://www.bitturk.net/ - - http://bitturk.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmler"} - - {id: 38, cat: Movies, desc: "Türk Filmi"} - - {id: 40, cat: Movies, desc: "Türkçe Film"} - - {id: 113, cat: Movies, desc: "Yabancı Dil"} - - {id: 77, cat: Movies/UHD, desc: "4K UHD / 3D"} - - {id: 16, cat: Movies, desc: "Animasyon"} - - {id: 31, cat: Movies, desc: "Belgesel"} - - {id: 112, cat: XXX, desc: "XxX"} - - {id: 48, cat: TV, desc: "Televizyon"} - - {id: 49, cat: TV/HD, desc: "Tv HD"} - - {id: 50, cat: TV, desc: "Yab@ncı Dizi"} - - {id: 51, cat: TV, desc: "Yerli Dizi"} - - {id: 7, cat: Audio, desc: "Müzik"} - - {id: 20, cat: Audio, desc: "Türkçe MüziK"} - - {id: 46, cat: Audio, desc: "Yab@ncı MüziK"} - - {id: 2, cat: Console, desc: "Oyun / Program"} - - {id: 44, cat: Books, desc: "E-BooK / Eğitim"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: info - type: info - label: Layout - default: "
  • Only the English Classic profile is supported.
  • Make sure to set the Torrent Listing (Listeleme Biçimi) option in your profile to Classic (Klasik)
  • And set the Language (Dil) to English
  • Using the Modern theme will prevent results, and using Turkish will prevent upload dates.
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and check that you are not being forced to change your password because it has expired after 365 days." - -login: - path: ?p=home&pid=1 - method: form - form: form#loginbox_form - submitpath: ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: ":contains(\"-ERROR-\")" - test: - path: ?p=home&pid=1 - selector: a[href*="/?p=logout&"] - -search: - paths: - - path: / - keywordsfilters: - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%25"] - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" -# Drop error trap to stop Sonarr disabling indexer, see https://github.com/Jackett/Jackett/issues/1415 -# error: -# - selector: div.error:not(:contains("Gösterilecek sonuç bulunamadı.")) - - rows: - selector: "table#torrents_table_classic tr:has(td.torrent_name){{ if .Config.freeleech }}:has(img[src$=\"/torrent_free.png\"]){{ else }}{{ end }}" - - fields: - title: - selector: a[href*="?p=torrents&pid=10&action=details"] - category: - selector: div.category_image > a - attribute: href - filters: - - name: querystring - args: cid - details: - selector: a[href*="?p=torrents&pid=10&action=details"] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download"] - attribute: href - date_day: - # Uploaded Friday at 05:11 by - # Uploaded Today at 00:48 by - # Uploaded Yesterday at 23:57 by - selector: td.torrent_name:contains(" at "):not(:has(abbr[data-time])) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - # Uploaded 30-01-2019 15:02 by - selector: td.torrent_name:not(:contains(" at ")):not(:has(abbr[data-time])) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - # within the hour (unix) - selector: abbr[data-time] - attribute: data-time - optional: true - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - size: - selector: a[rel="torrent_size"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - grabs: - selector: a[rel="times_completed"] - poster: - selector: a[rel="fancybox"] - attribute: href - downloadvolumefactor: - case: - "img[src$=\"/silver.gif\"]": 0.5 - "img[src$=\"/torrent_free.png\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "img[src$=\"/torrent_multiple_upload.png\"]": 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 day (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bjshare.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bjshare.yml deleted file mode 100644 index 132fec2f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bjshare.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: bjshare -name: Bj-Share -description: "Private PT-BR torrent" -language: pt-BR -type: private -encoding: UTF-8 -links: - - https://bj-share.info/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmes"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: PC, desc: "Aplicativos"} - - {id: 4, cat: PC/Games, desc: "Jogos"} - - {id: 5, cat: Books/Comics, desc: "Mangás"} - - {id: 6, cat: TV, desc: "Vídeos de TV"} - - {id: 7, cat: Other, desc: "Outros"} - - {id: 8, cat: TV/Sport, desc: "Esportes"} - - {id: 9, cat: Books/Mags, desc: "Revistas"} - - {id: 10, cat: Books, desc: "E-Books"} - - {id: 11, cat: Audio/Audiobook, desc: "Audiobook"} - - {id: 12, cat: Books/Comics, desc: "Histórias em Quadrinhos"} - - {id: 13, cat: TV, desc: "Stand Up Comedy"} - - {id: 14, cat: TV, desc: "TV/Anime"} # Anime format is equal to TV Show (SXXEXX) except old ones like one piece - - {id: 15, cat: XXX/ImageSet, desc: "Fotos Adultas"} - - {id: 16, cat: TV/Other, desc: "Desenho Animado"} - - {id: 17, cat: TV/Documentary, desc: "Documentários"} - - {id: 18, cat: Other, desc: "Cursos"} - - {id: 19, cat: XXX, desc: "Filmes Adultos"} - - {id: 20, cat: XXX/Other, desc: "Jogos Adultos"} - - {id: 21, cat: XXX/Other, desc: "Mangás Adultos"} - - {id: 22, cat: XXX/Other, desc: "Animes Adultos"} - - {id: 23, cat: XXX/Other, desc: "HQ Adultos"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="/logout.php?auth="] - -search: - # https://bj-share.info/torrents.php?searchstr=aves+de+rapina&filter_cat%5B1%5D=1 - paths: - - path: torrents.php - - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchstr: "{{ .Keywords }}" - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["(S[0-9]+E[0-9]+|S[0-9]+)", ""] # remove SXXEXX or SXX from search - - rows: - # If the category is a TV show/Anime, there's a necessity to filter the results by season/episode to not show all of them - selector: "table.torrent_table > tbody > tr:not(tr.colhead).group, - table.torrent_table tbody tr:not(tr.colhead):contains('{{ .Query.Episode }}')" - filters: - - name: andmatch - - fields: - download: - selector: a[title="Baixar"] - attribute: href - - _singleTorrentInfo: - selector: div.torrent_info - - _multipleTorrentInfo: - selector: a[href^="torrents.php?id="] - - _info: # example [MKV / x265 / WEB-DL / Legendado / 4K / Free] - text: "{{ if .Result._singleTorrentInfo }}{{.Result._singleTorrentInfo}}{{ else }}{{.Result._multipleTorrentInfo}}{{ end }}" - filters: - - name: replace - args: ["Full HD", "1080p"] - - name: replace - args: ["4K", "2160p"] - - name: replace - args: ["SD", "480p"] - - name: replace - args: ["/ HD]", "720p"] - - name: replace - args: ["/ HD /", "720p"] - - name: replace - args: ["/ Free", ""] - - name: re_replace - args: ["[\\[\\]]+", ""] - - _rawTitle: - selector: div.group_info - filters: - - name: re_replace - args: ["(\n.*)", ""] # remove everything after newline - - name: re_replace - args: [" |\t", ""] # remove double space and tabs - - _year: - text: "{{ .Result._rawTitle }}" - filters: - - name: regexp - args: "\\[([0-9]*)\\]" - - details: - selector: a[href^="torrents.php?id="]:not(.tooltip) - attribute: href - - title_MovieTV: # Movie and TV Format - # Title defined as: - # PT-BR/Japanese title [en-US title] [year] - text: "{{ .Result._rawTitle }}" - filters: - - name: re_replace - args: ["\\[[0-9]*\\].*", ""] # Removes the year and everything after - - name: re_replace - args: ["(.*)\\[(!?[^/]*?)\\]", "$2"] # Parse only en-us title, when available - - name: append - args: "{{ .Result._year }}" - - name: append - args: " {{ .Result._info }}" - - title_Other: - # Only remove brackets - text: "{{ .Result._rawTitle }}" - filters: - - name: re_replace - args: ["\\[|\\]", " "] # Remove Brackets - - title_details: - # Title defined as: - # PT-BR/Japanese title [en-US title] [year] - text: "{{ .Result._rawTitle }}" - filters: - - name: re_replace - args: ["\\[[0-9]*\\].*", ""] # Removes the year and everything after - - name: re_replace - args: ["(.*)\\[(!?[^/]*?)\\]", "$2"] # Parse only en-us title, when available - - category_details: - selector: td.cats_col > a - attribute: href - filters: - - name: regexp - args: "%5b(\\d+?)%5d" - - category: - text: "{{ .Result.category_details }}" - - title: - text: "{{ if - or (eq .Result.category_details \"1\") - (or (eq .Result.category_details \"2\") (or (eq .Result.category_details \"14\") - (or (eq .Result.category_details \"16\") (or (eq .Result.category_details \"17\") - (or eq .Result.category_details \"8\") - (or (eq .Result.category_details \"13\") (eq .Result.category_details \"6\"))))))) - }}{{.Result.title_MovieTV}}{{ else }}{{.Result.title_Other}}{{ end }}" - - size: - selector: td:nth-last-child(4) - grabs: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - downloadvolumefactor: - case: - strong[title*="Free"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluebird.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluebird.yml deleted file mode 100644 index 9f0151ce..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluebird.yml +++ /dev/null @@ -1,147 +0,0 @@ ---- -id: bluebirdhd -name: BlueBird -description: "BlueBird is a RUSSIAN Private Torrent Tracker for HD MOVIES" -language: ru-RU -type: private -encoding: windows-1251 -links: - - https://bluebird-hd.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Фильмы Films"} - - {id: 2, cat: TV/Anime, desc: "Мультфильмы Cartoons"} - - {id: 3, cat: TV/Documentary, desc: "Документалистика Documentary"} - - {id: 4, cat: Audio, desc: "Шоу/Музыка Show/Music"} - - {id: 5, cat: TV/Sport, desc: "Спорт Sport"} - - {id: 6, cat: TV, desc: "Сериалы TV Series"} - - {id: 7, cat: XXX, desc: "Эротика Erotica"} - - {id: 8, cat: Other, desc: "Дэмо/Misc Demo/Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.error - message: - text: "Error during login. Its likely your username or password is incorrect." - test: - path: / - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # search in title - search: "{{ if .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # search in description - dsearch: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}" - # 0 active, 1 incldead, 2 onlydead, 3 gold, 5 diamond, 4 without seeders - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - genre: "{{ .Query.Genre }}" - # or, and - stype: and - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[class="embedded"][cellspacing="0"][cellpadding="5"][width="100%"] > tbody#highlighted > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - grabs: - selector: td:nth-child(7) > nobr > a, td:nth-child(7) > nobr > b - date: - selector: div#frame > div#cleft - remove: a - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-child(7) - remove: a - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - genre: - selector: div#cleft - filters: - - name: replace - args: ["Жанр:", ""] - description: - text: "{{ .Result.genre }}" - downloadvolumefactor: - case: - img[src="pic/diamond.png"]: 0 - img[src="pic/freedownload.gif"]: 0 - img[src="pic/silver.gif"]: 0.5 - img[src="pic/bronze.gif"]: 0.75 - "*": 1 - uploadvolumefactor: - case: - img[src="pic/diamond.png"]: 2 - "*": 1 -# TBDev v2.1.12 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/blueroms.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/blueroms.yml deleted file mode 100644 index 682db31f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/blueroms.yml +++ /dev/null @@ -1,119 +0,0 @@ ---- -id: blueroms -name: BlueRoms -description: "BlueRoms is a Public Torrent Tracker for ROMS" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.blueroms.ws/ - -caps: - categorymappings: - - {id: "/arcade", cat: Console/Other, desc: "Arcade"} - - {id: "/dreamcast", cat: Console/Other, desc: "Dreamcast"} - - {id: "/gba", cat: Console/Other, desc: "Game Boy Advance"} - - {id: "/genesis", cat: Console/Other, desc: "Mega Drive - Genesis"} - - {id: "/neogeo", cat: Console/Other, desc: "Neo Geo"} - - {id: "/3ds", cat: Console/3DS, desc: "Nintendo 3DS"} - - {id: "/n64", cat: Console/NDS, desc: "Nintendo 64"} - - {id: "/nds", cat: Console/NDS, desc: "Nintendo DS"} - - {id: "/gamecube", cat: Console/Other, desc: "Nintendo GameCube"} - - {id: "/switch", cat: Console/NDS, desc: "Nintendo Switch"} - - {id: "/wii", cat: Console/Wii, desc: "Nintendo Wii"} - - {id: "/wiiu", cat: Console/WiiU, desc: "Nintendo Wii U"} - - {id: "/ps1", cat: Console/PS3, desc: "PlayStation"} - - {id: "/ps2", cat: Console/PS3, desc: "PlayStation 2"} - - {id: "/ps3", cat: Console/PS3, desc: "PlayStation 3"} - - {id: "/ps4", cat: Console/PS4, desc: "PlayStation 4"} - - {id: "/psp", cat: Console/PSP, desc: "PlayStation Portable"} - - {id: "/psvita", cat: Console/PS Vita, desc: "PlayStation Vita"} - - {id: "/saturn", cat: Console/Other, desc: "Saturn"} - - {id: "/snes", cat: Console/NDS, desc: "Super Nintendo"} - - {id: "/xbox", cat: Console/XBox, desc: "Xbox"} - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - paths: - # https://www.blueroms.ws/search?page=1&q=&g=0&p=0&ord=desc&at=date - - path: search - allowEmptyInputs: true - inputs: - page: 1 - q: "{{ .Keywords }}" - # genre: 0 all, 200 action, 207 adventure, etc - g: 0 - # categories does not support multi selection so defaulting to all - p: 0 - ord: "{{ .Config.type }}" - at: "{{ .Config.sort }}" - - rows: - selector: div.row > div.col-xs-12 - - fields: - category: - selector: p.card-text a - attribute: href - title_platform: - selector: p.card-text a - title: - selector: h4.card-title a - filters: - - name: append - args: " - {{ .Result.title_platform }}" - details: - selector: h4.card-title a - attribute: href - download: - selector: div.card-footer a - attribute: href - poster: - selector: img - attribute: src - size: - selector: p.card-text - filters: - - name: regexp - args: ([\d\.]+[T|G|M|K]iB) - date: - # release date is for game, not torrent - text: now - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: p.card-text - filters: - - name: regexp - args: "(Score: .+)" -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluetorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluetorrents.yml deleted file mode 100644 index 91ab1a53..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluetorrents.yml +++ /dev/null @@ -1,121 +0,0 @@ ---- -id: bluetorrents -name: BlueTorrents -description: "BlueTorrents is a Private Torrent Tracker for MOVIES / TV / MUSIC / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://bluetorrents.com/ - -caps: - categorymappings: - # from https://bluetorrents.com/api/external?apikey=YOUR-API-KEY&t=caps - - {id: 2000, cat: Movies, desc: "Movies"} - - {id: 5000, cat: TV, desc: "TV Shows"} - - {id: 1000, cat: Console, desc: "Games"} - - {id: 3000, cat: Audio, desc: "Music"} - - {id: 4000, cat: PC/0day, desc: "Apps"} - - {id: 6000, cat: XXX, desc: "XXX"} - - {id: 8000, cat: Other, desc: "Other"} - - {id: 7000, cat: Books, desc: "Books"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, tmdbid] - music-search: [q] - book-search: [q] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: API Key - - name: info_key - type: info - label: About your API key - default: "You can find your API Key by accessing the BlueTorrents Settings page, and scrolling down to the API keys section." - - name: info_3x - type: info - label: Including 3X - default: "IF you want to see 3x torrents access your BlueTorrents Settings page and tick the Show NSFW content checkbox." - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts with no activity for 90+ days may be disabled." - -search: - paths: - - path: api/external - response: - type: xml - - inputs: - apikey: "{{ .Config.apikey }}" - t: "{{ if .Query.TMDBID }}movie{{ else }}{{ end }}{{ if or .Query.Season .Query.Ep }}tvsearch{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.TMDBID .Query.Season .Query.Ep }}{{ else }}search{{ end }}" - q: "{{ .Keywords }}" - cat: "{{ join .Categories \",\" }}" - season: "{{ .Query.Season }}" - ep: "{{ .Query.Ep }}" - imdbid: "{{ .Query.IMDBID }}" - tmdbid: "{{ .Query.TMDBID }}" - limit: 100 - - keywordsfilters: - # replace spaces between words with percent wildcard - - name: re_replace - args: ["\\W+", "%"] - - rows: - selector: rss > channel > item - - fields: - category: - selector: '[name="category"]' - attribute: value - title: - selector: title - details: - selector: link - download: - selector: enclosure - attribute: url - infohash: - selector: '[name="infohash"]' - attribute: value - imdbid: - selector: '[name="imdbid"]' - attribute: value - tmdbid: - selector: '[name="tmdbid"]' - attribute: value - date: - # Sun, 08 Feb 2026 03:32:24 -0800 - selector: pubDate - filters: - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - size: - selector: size - seeders: - selector: '[name="seeders"]' - attribute: value - leechers: - selector: '[name="peers"]' - attribute: value - grabs: - selector: '[name="grabs"]' - attribute: value - downloadvolumefactor: - selector: '[name="downloadvolumefactor"]' - attribute: value - uploadvolumefactor: - selector: '[name="uploadvolumefactor"]' - attribute: value - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluraytracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluraytracker.yml deleted file mode 100644 index 5fdb5cf1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bluraytracker.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -id: bluraytracker -name: BlurayTracker -description: "BlurayTracker is a CZECH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: cs-CZ -type: private -encoding: UTF-8 -links: - - https://bluraytracker.cz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "-----Filmy-----"} - - {id: 34, cat: Movies, desc: " |- Filmy CZ/SK Dabing"} - - {id: 33, cat: Movies, desc: " |- Filmy CZ/SK Titulky"} - - {id: 49, cat: Movies/HD, desc: " |- HD/FHD Filmy"} - - {id: 17, cat: Movies/UHD, desc: " |- 4k/UHD Filmy "} - - {id: 31, cat: Movies/BluRay, desc: " |- Bluray DB/BD/DVD"} - - {id: 51, cat: Movies/3D, desc: " |- 3D Filmy"} - - {id: 45, cat: Movies/Other, desc: " |- Kreslené/Anime"} - - {id: 40, cat: TV, desc: "-----TV-----"} - - {id: 32, cat: TV, desc: " |- Serialy CZ/SK/EN"} - - {id: 47, cat: TV, desc: " |- Serialy"} - - {id: 38, cat: TV/Documentary, desc: " |- Dokumenty / TV Reality"} - - {id: 35, cat: TV/Sport, desc: " |- Sport"} - - {id: 6, cat: PC, desc: "Aplikace/Programy"} - - {id: 18, cat: PC/0day, desc: " |- Programy PC"} - - {id: 19, cat: PC/Mac, desc: " |- Programy MacOS"} - - {id: 7, cat: Audio, desc: "-----Hudba-----"} - - {id: 39, cat: Audio/Audiobook, desc: " |- Mluvene Slovo"} - - {id: 21, cat: Audio/MP3, desc: " |- MP3"} - - {id: 43, cat: Audio/Other, desc: " |- SoundTrack"} - - {id: 53, cat: Audio/Lossless, desc: " |- Flac"} - - {id: 2, cat: PC/Games, desc: "--Hry--"} - - {id: 3, cat: Console/PS3, desc: " |- Sony PS"} - - {id: 4, cat: Console/Wii, desc: " |- Wii"} - - {id: 26, cat: Console/XBox, desc: " |- XboX"} - - {id: 27, cat: PC/Games, desc: " |- PC"} - - {id: 28, cat: Console/NDS, desc: " |- Nintendo"} - - {id: 42, cat: Books, desc: "Knihy A Časopisy"} - - {id: 36, cat: Other, desc: "Ostatní"} - # not listed - - {id: 41, cat: Movies, desc: "VIP Torrent"} - - {id: 48, cat: Movies, desc: " |- RLS"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: ?p=home&pid=1 - method: form - form: form#sls_form - submitpath: ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: true - error: - - selector: div.error - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - - path: / - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - # does not support imdbid search but has imdbid links in results. - error: - - selector: div.error:not(:contains("There are no results found.")) - - rows: - selector: "div.torrent-box[id^=\"torrent_\"]{{ if .Config.freeleech }}:has(img[src$=\"/torrent_free.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="?p=torrents&pid=10&cid="] - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - details: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download&tid="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - optional: true - poster: - selector: a[href*="data/torrents/torrent_images/"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: a[rel="torrent_size"] - grabs: - selector: a[rel="times_completed"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - date_day: - selector: div.torrentOwner:contains("day") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - selector: div.torrentOwner:contains("-") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - selector: div.torrentOwner abbr - optional: true - attribute: data-time - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img[src$="/torrent_free.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/blutopia-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/blutopia-api.yml deleted file mode 100644 index cb6219de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/blutopia-api.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: blutopia-api -name: Blutopia (API) -description: "Blutopia (BLU) is a Private Torrent Tracker for HD MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://blutopia.cc/ -legacylinks: - - https://blutopia.xyz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 8, cat: Other, desc: "Other"} - - {id: 2, cat: TV, desc: "TV Show"} - - {id: 3, cat: Movies/Other, desc: "FANRES"} - - {id: 5, cat: Movies/Other, desc: "Trailer"} - - {id: 9, cat: Audio/Video, desc: "Live Concert"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Blutopia account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your membership requires that you stay active at Blutopia. To not get disabled, you are required to login to the site at least once every 30 days or be actively seeding at least 1 torrent." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/booktracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/booktracker.yml deleted file mode 100644 index 749c6d4a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/booktracker.yml +++ /dev/null @@ -1,102 +0,0 @@ ---- -id: booktracker -name: BookTracker -description: "BookTracker is a RUSSIAN Semi-Private Torrent Tracker for EBOOKS" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://booktracker.org/ - -caps: - categorymappings: - - {id: 1, cat: Books/EBook, desc: "Ebooks"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form[action$="/login.php"] - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - redirect: "index.php" - autologin: 1 - selectorinputs: - cookie_test: - selector: input[name="cookie_test"] - attribute: value - error: - - selector: h4.warnColor1 - test: - path: index.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - - path: tracker.php - inputs: - nm: "{{ .Keywords }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tm: -1 - sns: -1 - - rows: - selector: tr[id^="tor_"]:has(a[href^="./download.php?id="]) - filters: - - name: andmatch - - fields: - category: - text: 1 - title: - selector: a.tLink - details: - selector: a.tLink - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - selector: td:last-child > u - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bootytape.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bootytape.yml deleted file mode 100644 index 970fff7b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bootytape.yml +++ /dev/null @@ -1,136 +0,0 @@ ---- -id: bootytape -name: BootyTape -description: "BootyTape is a Semi-Private Torrent Tracker for 3X" -language: en-US -type: semi-private -encoding: UTF-8 -requestDelay: 3 -links: - - https://ssl.bootytape.com/ - -caps: - categorymappings: - - {id: 70, cat: XXX, desc: "Animation"} - - {id: 22, cat: XXX, desc: "Girl on Girl"} - - {id: 43, cat: XXX, desc: "Images"} - - {id: 52, cat: XXX, desc: "Straight"} - - {id: 82, cat: XXX, desc: "X Play Hard"} - - {id: 92, cat: XXX, desc: "X Play Soft"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: login.php - method: post - inputs: - take_login: 1 - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.mainouter2:has(h1:contains("Failed")) - test: - path: my.php - selector: a[href="logout.php"] - -search: - paths: - # https://ssl.bootytape.com/torrents.php?search=onlyfans&tagsearch=&cat=0&tags=1 - - path: torrents.php - inputs: - search: "{{ .Keywords }}" - tagsearch: "" - # 0 all - cat: 0 - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - # tags search mode: 1 AND, 2 OR - tags: 1 - # incldead=2 for search unseeded - # descr=1 for search description - # double=1 for search double seed bonus - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table.red > tbody > tr:not(:has(th)) - - fields: - category: - selector: a[href^="torrents.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - description: - selector: div.specg - poster: - selector: img[src*="/thbx/imgx/"] - attribute: src - files: - selector: a[href$="filelist=1"] - optional: true - default: 1 - size: - selector: td:nth-child(3) - optional: true - default: 0 - seeders: - selector: a[href$="toseeders=1"] - optional: true - default: 0 - leechers: - selector: a[href$="todlers=1"] - optional: true - default: 0 - date: - text: now - downloadvolumefactor: - case: - "img[src$=\"pic/freedownload.gif\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "img[src$=\"pic/double-upload.gif\"]": 2 - "*": 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/borgzelle.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/borgzelle.yml deleted file mode 100644 index 1777524b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/borgzelle.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -id: borgzelle -name: Borgzelle -description: "Borgzelle is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://borgzelle.borg/ -certificates: - - a3f8822c97fbfe5bd00760cf8e6b75c4284f657d # self signed - -caps: - categorymappings: - - {id: 17, cat: TV/Sport, desc: "Sport"} - - {id: 32, cat: TV, desc: "Serien-Pakete"} - - {id: 12, cat: TV, desc: "Serien"} - - {id: 14, cat: Audio/Video, desc: "Musikvideo"} - - {id: 15, cat: TV/Documentary, desc: "Doku"} - - {id: 16, cat: TV/Other, desc: "Comedy"} - - {id: 18, cat: TV/Anime, desc: "Anime"} - - {id: 6, cat: Movies/SD, desc: "Movie XviD/DivX"} - - {id: 34, cat: Movies/UHD, desc: "Movie UHD"} - - {id: 13, cat: Movies, desc: "Movie Kinder"} - - {id: 11, cat: Movies/HD, desc: "Movie HD2DVD"} - - {id: 9, cat: Movies/HD, desc: "Movie HD"} - - {id: 33, cat: Movies/HD, desc: "Movie h265"} - - {id: 7, cat: Movies/HD, desc: "Movie h264"} - - {id: 10, cat: Movies/DVD, desc: "Movie DVD"} - - {id: 28, cat: PC/Games, desc: "Spiele PDA/Handy"} - - {id: 25, cat: PC/Games, desc: "Spiele PC"} - - {id: 27, cat: PC/Games, desc: "Spiele MAC"} - - {id: 26, cat: Console, desc: "Spiele Konsolen"} - - {id: 30, cat: Other, desc: "Diverse Wallpaper"} - - {id: 31, cat: Other, desc: "Sonstiges"} - - {id: 29, cat: Books/EBook, desc: "E-Book"} - - {id: 19, cat: Audio, desc: "Musik"} - - {id: 20, cat: Audio/Audiobook, desc: "Hörbuch"} - - {id: 21, cat: PC/0day, desc: "Software Windows"} - - {id: 23, cat: PC/Mobile-Other, desc: "Software PDA/Handy"} - - {id: 22, cat: PC/Mac, desc: "Software MAC"} - - {id: 24, cat: PC, desc: "Software Linux"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_sitelink - type: info - label: About Site Link - default: If the Site Link domain returns a DNS error then you have not yet been assimilated. - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: font[color="red"] - test: - path: index.php - selector: a[href^="logout.php?auth="] - -search: - paths: - # https://borgzelle.borg/browse.php?action=browse&showsearch=1&c34=1&c9=1&search=&incldead=0&orderby=added&sort=desc - - path: browse.php - inputs: - action: browse - showsearch: 1 - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 all, 1 active, 2 dead - incldead: 0 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # the site does not support imdbid searching or present imdb links in results - - rows: - selector: "table[style=\"width: 100%;\"] > tbody > tr{{ if .Config.freeleech }}:has(span:contains(\"[OnlyUpload]\")){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="torrents.php?torrentid="] - details: - selector: a[href^="torrents.php?torrentid="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - poster: - selector: a[href^="torrents.php?torrentid="] span img - attribute: src - date: - # 17.10. 18:34 (vor 2 Stunden und 57 Minuten) - selector: td:nth-child(2) > table > tbody > tr > td:nth-child(2) - filters: - - name: regexp - args: "\\((.+?)\\)" - # translations for Turkish|Estonian|Danish|Italian|Polish|Norwegian|Portuguese|Czech|Russian|Romanian|Spanish|French|German|Bulgarian|Dutch|Chinese|Japanese|Swedish - - name: re_replace - args: ["(?i)(önce|tagasi|geleden|fa|temu|siden|há|atrás|nazpět|назад|acum|în urmă|hace|il y a|vor|преди|前|sedan)", " ago"] - - name: re_replace - args: ["(?i)(saniye|sekundit|sekunder|secondi|sekund|segundos|sekundami|секунд|secunde|secondes|Sekunden|секунди|seconden|秒前)", "seconds"] - - name: re_replace - args: ["(?i)(minutit|minutter|minuti|minuty|minutos|минуты|минут|Minuten|минути|minuten|minuter)", "minutes"] - - name: re_replace - args: ["(?i)(dakika|minut|minuto|minuta|minutt|минута|Minute|minuut|分钟|分)", " minute"] - - name: re_replace - args: ["(?i)(tundi|timer|ore|godziny|horas|hodiny|hoden|часа|часов|ore|heures|Stunden|timmar)", "hours"] - - name: re_replace - args: ["(?i)(saat|tund|time|ora|godzina|hora|hodina|час|oră|heure|Stunde|uur|小时|時間|timme)", " hour"] - - name: re_replace - args: ["(?i)(päeva|dage|giorni|dni|dias|dny|дня|дней|zile|días|jours|Tagen|дни|dagen|dagar)", "days"] - - name: re_replace - args: ["(?i)(gün|päev|dag|giorno|dzień|dia|den|день|zi|día|jour|Tag|ден|天|日)", " day"] - - name: re_replace - args: ["(?i)(nädalat|uger|settimane|tygodnie|uker|semanas|týdny|недели|недель|săptămâni|semaines|Wochen|седмици|weken|veckor)", "weeks"] - - name: re_replace - args: ["(?i)(hafta|nädal|uge|settimana|tydzień|uke|semana|týden|неделю|săptămână|semaine|Woche|седмица|周|週間|vecka)", " week"] - - name: re_replace - args: ["(?i) (ay)", "month"] - - name: re_replace - args: ["(?i)(kuud|måneder|mesi|miesiące|meses|měsíce|месяца|месяцев|luni|meses|mois|Monaten|месеца|maanden|månader)", "months"] - - name: re_replace - args: ["(?i)(kuu|måned|mese|miesiąc|mês|měsíc|месяц|lună|mes|Monat|месец|maand|个月|ヶ月|månad)", " month"] - - name: re_replace - args: ["(?i)(aastat|anni|lata|anos|roky|года|ani|años|ans|Jahren|години)", " years"] - - name: re_replace - args: ["(?i)(yil|aasta|år|anno|rok|ano|год|año|Jahr|година|jaar|年)", " year"] - - name: re_replace - args: ["(?i) (an)", "year"] - - name: re_replace - args: ["(?i)(För |und)", ""] # Misc removals - - name: timeago - size: - selector: td:nth-child(2) > table > tbody > tr:nth-child(2) > td:nth-child(1) b - files: - selector: td:nth-child(2) > table > tbody > tr:nth-child(2) > td:nth-child(1) a span - seeders: - selector: td:nth-child(2) > table > tbody > tr:nth-child(2) > td:nth-child(2) a span - leechers: - selector: td:nth-child(2) > table > tbody > tr:nth-child(2) > td:nth-child(2) a span:nth-child(2) - grabs: - selector: td:nth-child(2) > table > tbody > tr:nth-child(2) > td:nth-child(3) a span - downloadvolumefactor: - case: - "span:contains(\"[OnlyUpload]\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/boxingtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/boxingtorrents.yml deleted file mode 100644 index d8419745..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/boxingtorrents.yml +++ /dev/null @@ -1,151 +0,0 @@ ---- -id: boxingtorrents -name: Boxing Torrents -description: "Boxing Torrents is a Private Torrent Tracker for BOXING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://tc-boxing.com/ - -caps: - categorymappings: - - {id: 17, cat: TV/Sport, desc: "4K"} - - {id: 16, cat: TV/Sport, desc: "Blu-ray"} - - {id: 14, cat: TV/Sport, desc: "HD"} - - {id: 8, cat: TV/Sport, desc: "DVD"} - - {id: 1, cat: TV/Sport, desc: "Full - Excellent"} - - {id: 3, cat: TV/Sport, desc: "Full - Fair"} - - {id: 2, cat: TV/Sport, desc: "Full - Good"} - - {id: 4, cat: TV/Sport, desc: "Full - Poor"} - - {id: 10, cat: TV/Sport, desc: "Foreign - Excellent"} - - {id: 11, cat: TV/Sport, desc: "Foreign - Fair"} - - {id: 6, cat: TV/Sport, desc: "Foreign - Good"} - - {id: 12, cat: TV/Sport, desc: "Foreign - Poor"} - - {id: 13, cat: TV/Sport, desc: "Career Set"} - - {id: 19, cat: TV/Documentary, desc: "Documentary"} - - {id: 9, cat: TV/Sport, desc: "Amateur - Full"} - - {id: 18, cat: TV/Sport, desc: "Instructional"} - - {id: 7, cat: TV/Sport, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false -# - name: sort -# type: select -# label: Sort requested from site -# default: added -# options: -# added: created -# seeders: seeders -# size: size -# name: title -# - name: type -# type: select -# label: Order requested from site -# default: DESC -# options: -# DESC: desc -# ASC: asc - - name: info_download - type: info - label: "Download Restrictions" - default: "You must have uploaded at least 10GB before you are able to access all torrent sizes. Until then, you are restricted to torrents no larger than 2.5GB." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:contains("Login failed!") - test: - path: browse.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: a[href^="download.php"] - attribute: href - -search: - paths: - # https://tc-boxing.com/browse.php?c16=1&c8=1&c14=1&incldead=1 - # https://tc-boxing.com/browse.php?search=tyson&cat=0&incldead=1 - # https://tc-boxing.com/browse.php?search=medina&cat=0&incldead=1&screen=1&champ=1&free=1 - - path: browse.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}c{{.}}=1&{{end}}{{ else }}cat=0{{ end }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 1 freeleech only, null all - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - search: "{{ .Keywords }}" -# dropped sorting as this prevents the dateheaders from results! -# sort: "{{ .Config.sort }}" -# d: "{{ .Config.type }}" - - rows: - selector: table.torrenttable > tbody > tr:has(a[href^="details.php?id="]) - filters: - - name: andmatch - dateheaders: - selector: td[colspan] > b - filters: - - name: append - args: " -07:00" # PDT - - name: replace - args: ["Torrents added ", ""] - - name: dateparse - args: "dddd, d. MMM, yyyy zzz" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - files: - selector: td:nth-last-child(7) - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - "b:contains(\"FreeLeech\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 day (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# BT 4.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/brasiltracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/brasiltracker.yml deleted file mode 100644 index c84958cb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/brasiltracker.yml +++ /dev/null @@ -1,183 +0,0 @@ ---- -id: brasiltracker -name: BrasilTracker -description: "BrasilTracker is a BRAZILIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pt-BR -encoding: UTF-8 -type: private -links: - - https://brasiltracker.org/ - -caps: - categorymappings: - - {id: 16, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 6, cat: TV/Anime, desc: "Animes"} - - {id: 11, cat: PC/0day, desc: "Aplicativos"} - - {id: 15, cat: Other, desc: "Cursos"} - - {id: 8, cat: TV/Documentary, desc: "Documentários"} - - {id: 14, cat: TV/Sport, desc: "Esportes"} - - {id: 3, cat: XXX, desc: "Filmes XXX"} - - {id: 1, cat: Movies, desc: "Filmes"} - - {id: 12, cat: Books/Comics, desc: "Histórias em Quadrinhos"} - - {id: 9, cat: PC/Games, desc: "Jogos"} - - {id: 13, cat: Books/EBook, desc: "Livros"} - - {id: 10, cat: Books/Mags, desc: "Revistas"} - - {id: 2, cat: TV, desc: "Séries"} - - {id: 5, cat: Audio/Video, desc: "Show"} - - {id: 7, cat: TV, desc: "Televisão"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q, imdbid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="logout.php?auth="] - -search: - paths: - # https://brasiltracker.org/torrents.php?order_by=time&order_way=desc&freetorrent=1&filter_cat[6]=1&filter_cat[3]=1&action=basic&searchsubmit=1 - # https://brasiltracker.org/torrents.php?searchstr=mandalorain&order_by=size&order_way=desc&action=basic&searchsubmit=1 - # https://brasiltracker.org/torrents.php?searchstr=tt8179024&order_by=time&order_way=desc&action=basic&searchsubmit=1 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchstr: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - group_results: 0 - action: basic - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - searchsubmit: 1 - - rows: - selector: table#torrent_table > tbody > tr.torrent - - fields: - category: - selector: td.big_info div.group_info - case: - "div:contains(\"[Audiobooks]\")": 16 - "div:contains(\"[Animes]\")": 6 - "div:contains(\"[Aplicativos]\")": 11 - "div:contains(\"[Cursos]\")": 15 - "div:contains(\"[Documentários]\")": 8 - "div:contains(\"[Esportes]\")": 14 - "div:contains(\"[Filmes XXX]\")": 3 - "div:contains(\"[Filmes]\")": 1 - "div:contains(\"[Histórias em Quadrinhos]\")": 12 - "div:contains(\"[Jogos]\")": 9 - "div:contains(\"[Livros]\")": 13 - "div:contains(\"[Revistas]\")": 10 - "div:contains(\"[Séries]\")": 2 - "div:contains(\"[Show]\")": 5 - "div:contains(\"[Televisão]\")": 7 - "*": 0 # catch errors - details: - selector: a[href^="torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description: - selector: div.tags - poster: - selector: img[alt="Cover"] - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - tmdbid: - selector: a[href*="themoviedb.org/movie/"], a[href*="themoviedb.org/tv/"] - attribute: href - files: - selector: td:nth-child(3) - date: - selector: span.time - attribute: title - filters: - - name: append - args: " -03:00" # BRT - - name: dateparse - args: "MMM d yyyy, HH:mm zzz" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - strong.tl_free: 0 - "*": 1 - uploadvolumefactor: - text: 1 - title_details: - selector: div.torrent_info - remove: strong - year: - selector: td.big_info div.group_info - optional: true - filters: - - name: regexp - args: "\\[((?:19|20)\\d{2})\\]" - title: - selector: a[href^="torrents.php?id="] - filters: - - name: re_replace - args: ["\\[([^\\]]+)\\]", " / $1"] - - name: append - args: "{{ if .Result.year }} ({{ .Result.year }}){{ else }}{{ end }}" - - name: append - args: " {{ .Result.title_details }}" - - name: re_replace - args: ["\\bWEB\\b(?![-_. ]?DL)", "WEB-DL"] - - name: re_replace - args: ["(?i)\\b4K\\b", "2160p"] - - name: re_replace - args: ["(?i)\\bFull HD\\b", "1080p"] - - name: re_replace - args: ["(?i)\\b(?10 - categorymappings: - - {id: 01, cat: Movies, desc: "Cine Arg."} - - {id: 02, cat: Movies/Foreign, desc: "Cine Int."} - - {id: 03, cat: TV, desc: "TV Arg."} - - {id: 04, cat: TV/Foreign, desc: "TV Int."} - - {id: 05, cat: XXX, desc: "XXX"} - - {id: 06, cat: TV/Anime, desc: "Anime"} - - {id: 07, cat: TV/Sport, desc: "Deportes"} - - {id: 08, cat: Audio/Video, desc: "Video Clips"} - - {id: 09, cat: Audio, desc: "Musica Arg."} - - {id: 10, cat: Audio/Foreign, desc: "Musica Int."} - - {id: 11, cat: Movies/DVD, desc: "DVD-R"} - - {id: 12, cat: PC/Games, desc: "Juegos"} - - {id: 13, cat: PC/0day, desc: "Software Arg."} - - {id: 14, cat: PC/0day, desc: "Software Int."} - - {id: 15, cat: Other, desc: "Otros"} - - {id: 16, cat: Other, desc: "Subpacks"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Por página Torrents: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Those new accounts that do not register a transfer or have any movement in the forum after 4 weeks will be disabled." - -login: - path: tracker/takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("fallado")) - test: - path: tracker/index.php - selector: a[href="logout.php"], a[href^="download.php/"] - -search: - paths: - - path: tracker/browse.php - inputs: - $raw: "{{ range .Categories }}cat[]={{.}}&{{end}}" - keywords: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 title, 1 title+descr, 2 descr - type: "{{ if .Query.IMDBID }}2{{ else }}0{{ end }}" - # 0 active, 1 incldead, 2 dead - incldead: 0 # dead results don't have torrents - # 0 all, 1 free, 2 normal - inclfree: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - list: 1 - - keywordsfilters: - - name: re_replace - args: ["(?i)(S(\\d{1,3}))(?![\\dE])", "Temporada"] - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: table > tbody > tr:not(tr:first-child):not(tr.browse) - after: 1 - - fields: - categorydesc: - selector: a[href*="?cat="] img - attribute: title - title_default: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["(?i)(Primera Temporada)", "S01"] - - name: re_replace - args: ["(?i)(Segunda Temporada)", "S02"] - - name: re_replace - args: ["(?i)(Tercera Temporada)", "S03"] - - name: re_replace - args: ["(?i)(Cuarta Temporada)", "S04"] - - name: re_replace - args: ["(?i)(Quinta Temporada)", "S05"] - - name: re_replace - args: ["(?i)(Sexta Temporada)", "S06"] - - name: re_replace - args: ["(?i)(Septima Temporada)", "S07"] - - name: re_replace - args: ["(?i)(Octava Temporada)", "S08"] - - name: re_replace - args: ["(?i)(Novena Temporada)", "S09"] - - name: re_replace - args: ["(?i)(Decima Temporada)", "S10"] - - name: re_replace - args: ["(?i)(Onceava Temporada)", "S11"] - - name: re_replace - args: ["(?i)(Decimosegunda Temporada)", "S12"] - - name: re_replace - args: ["(?i)Temporada (\\d{1,3})\\s*y\\s*(\\d{1,3})", "S$1-$2"] - - name: re_replace - args: ["(?i)Temporada (\\d{1,3})", "S$1"] - # move ARG tag to end - - name: re_replace - args: ["^(\\[ARG\\]) (.+?)$", "$2 $1"] - # move quality tags to end - - name: re_replace - args: ["^(\\[.+?\\]) (.+?)$", "$2 $1"] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - filters: - - name: re_replace - args: ["(?i)(Primera Temporada)", "S01"] - - name: re_replace - args: ["(?i)(Segunda Temporada)", "S02"] - - name: re_replace - args: ["(?i)(Tercera Temporada)", "S03"] - - name: re_replace - args: ["(?i)(Cuarta Temporada)", "S04"] - - name: re_replace - args: ["(?i)(Quinta Temporada)", "S05"] - - name: re_replace - args: ["(?i)(Sexta Temporada)", "S06"] - - name: re_replace - args: ["(?i)(Septima Temporada)", "S07"] - - name: re_replace - args: ["(?i)(Octava Temporada)", "S08"] - - name: re_replace - args: ["(?i)(Novena Temporada)", "S09"] - - name: re_replace - args: ["(?i)(Decima Temporada)", "S10"] - - name: re_replace - args: ["(?i)(Onceava Temporada)", "S11"] - - name: re_replace - args: ["(?i)(Decimosegunda Temporada)", "S12"] - - name: re_replace - args: ["(?i)Temporada (\\d{1,3})\\s*y\\s*(\\d{1,3})", "S$1-$2"] - - name: re_replace - args: ["(?i)Temporada (\\d{1,3})", "S$1"] - # move ARG tag to end - - name: re_replace - args: ["^(\\[ARG\\]) (.+?)$", "$2 $1"] - # move quality tags to end - - name: re_replace - args: ["^(\\[.+?\\]) (.+?)$", "$2 $1"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - date: - selector: td:nth-child(5) - remove: font - filters: - - name: append - args: " +00:00" # auto adjusted by site account profile - - name: dateparse - args: "yyyy-MM-dd zzz" - size: - selector: td:nth-child(3) - seeders: - selector: td:nth-child(8) - filters: - - name: regexp - args: "^(\\d+) \/" - leechers: - selector: td:nth-child(8) - filters: - - name: regexp - args: "\/ (\\d+)$" - grabs: - selector: td:nth-child(9) - filters: - - name: regexp - args: "\\\xA0(\\d+)\\\xA0" - downloadvolumefactor: - case: - "font[color=\"red\"]:contains(\"FREE!\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 - description_default: - selector: a[href^="details.php?id="] - description: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.description_default }}" -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btdirectory.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btdirectory.yml deleted file mode 100644 index 54b4c966..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btdirectory.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: btdirectory -name: BTdirectory -description: "BTdirectory (BT目录) is a Public DHT Crawler" -language: en-US -type: public -encoding: UTF-8 -# current domain at https://cursor.vip/btmulu then follow links to the final search-movie link -links: - - https://btmulu.live/ - - https://www.btmulu.cyou/ - - https://www.btmulu.quest/ - - https://www.btmulu.cfd/ - - https://www.btmulu.help/ -legacylinks: - - https://www.btmulu.asia/ - - https://www.btmulu.digital/ - - https://www.btmulu.pw/ - - https://www.btmulu.one/ - - https://btmulu.work/ - -caps: - categorymappings: - - {id: Other, cat: Other, desc: Other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - - name: info_flaresolverr - type: info_flaresolverr - -search: - paths: - # https://www.btmulu.digital/search-create.html - # https://btmulu.work/search.html?name=the+boys+s04e08 - - path: "{{ if .Keywords }}search.html{{ else }}search-create.html{{ end }}" - inputs: - name: "{{ re_replace .Keywords \"[\\s]+\" \"+\" }}" - - rows: - selector: article.item:has(a[href^="/hash/"]) - filters: - - name: andmatch - - fields: - category: - text: Other - title: - selector: h4 - remove: span - details: - selector: a[href^="/hash/"] - attribute: href - infohash: - selector: a[href^="/hash/"] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - date: - selector: p - filters: - - name: regexp - args: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) - files: - selector: p - filters: - - name: regexp - args: "File Count:(\\d+)" - size: - selector: p - filters: - - name: regexp - args: "Size:(\\d+\\.?\\d+ [K|M|G]B)" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btetree.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btetree.yml deleted file mode 100644 index 513f759b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btetree.yml +++ /dev/null @@ -1,77 +0,0 @@ ---- -id: btetree -name: BT.etree -description: "BT.etree is a Public Tracker dedicated to Bootleg FLAC MUSIC" -language: en-US -type: public -encoding: UTF-8 -links: - - https://bt.etree.org/ -legacylinks: - - http://bt.etree.org/ - -caps: - categories: - Music: Audio/Lossless - - modes: - search: [q] - music-search: [q, artist] - -settings: - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - name: title - -search: - paths: - # http://bt.etree.org/?searchzzzz=&cat=0 - - path: / - inputs: - searchzzzz: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ .Keywords }}{{ end }}" - cat: 0 - sort: "{{ .Config.sort }}" - - rows: - selector: table[bgcolor="#CCCCCC"] tbody tr:has(a[href^="download.php"]) - filters: - - name: andmatch - - fields: - category: - text: Music - title: - selector: a.details_link - details: - selector: a.details_link - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(5) - filters: - - name: replace - args: [" ", " 0"] - - name: dateparse - args: "MM/dd HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btnext.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btnext.yml deleted file mode 100644 index bd51385a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btnext.yml +++ /dev/null @@ -1,278 +0,0 @@ ---- -id: btnext -name: BTNext -description: "BTNext (BTNT) is a PORTUGUESE Private Torrent Tracker for 0DAY / GENERAL" -language: pt-PT -type: private -encoding: UTF-8 -links: - - https://tracker.btnext.com/ # site forces https -legacylinks: - - http://tracker.btnext.com/ - -caps: - categorymappings: - - {id: 86, cat: Other, desc: "Freeleech"} - - {id: 88, cat: Movies, desc: "Movies"} - - {id: 89, cat: Console, desc: "Games"} - - {id: 87, cat: TV, desc: "Series"} - - {id: 93, cat: Audio, desc: "Musica"} - - {id: 108, cat: Movies, desc: "ViP"} - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 70, cat: Movies/3D, desc: "Movies 3D"} - - {id: 71, cat: Movies/SD, desc: "Movies 480p"} - - {id: 72, cat: Movies/BluRay, desc: "Movies BD-R"} - - {id: 31, cat: Movies/HD, desc: "Movies BDRIP/BRRip"} - - {id: 16, cat: Movies/SD, desc: "Movies CAM"} - - {id: 74, cat: Movies/DVD, desc: "Movies DvD-R"} - - {id: 76, cat: Movies, desc: "Movies Packs"} - - {id: 115, cat: Movies/Other, desc: "Movies Kids"} - - {id: 91, cat: Movies/Other, desc: "Movies Kids Inglês"} - - {id: 113, cat: Movies/Other, desc: "Movies Kids PT-BR"} - - {id: 114, cat: Movies/Other, desc: "Movies Kids PT-PT"} - - {id: 77, cat: Movies/WEB-DL, desc: "Movies WEB-DL"} - - {id: 85, cat: Movies/DVD, desc: "Movies Custom-DVDR"} - - {id: 14, cat: Movies/SD, desc: "Movies DVDRip/XViD"} - - {id: 98, cat: Movies/Other, desc: "Movies Juvenil"} - - {id: 104, cat: Movies/UHD, desc: "Movies Bluray-UHD"} - - {id: 102, cat: Movies/HD, desc: "Movies HD-720p"} - - {id: 101, cat: Movies/HD, desc: "Movies HD-1080p"} - - {id: 103, cat: Movies/UHD, desc: "Movies HD-2160p"} - - {id: 42, cat: TV, desc: "TV Series"} - - {id: 45, cat: TV/Documentary, desc: "Documentaries"} - - {id: 46, cat: TV/Sport, desc: "Sports"} - - {id: 47, cat: TV/HD, desc: "TV Bluray"} - - {id: 48, cat: TV/SD, desc: "TV Dvd-R"} - - {id: 49, cat: TV/SD, desc: "TV DVDRiP"} - - {id: 50, cat: TV, desc: "TV Packs"} - - {id: 92, cat: TV, desc: "TV Kids"} - - {id: 112, cat: TV, desc: "TV Juvenil"} - - {id: 52, cat: TV/SD, desc: "TV SDx264"} - - {id: 53, cat: TV/WEB-DL, desc: "TV WEB-DL"} - - {id: 55, cat: TV/HD, desc: "TV X264"} - - {id: 56, cat: TV/SD, desc: "TV-XViD"} - - {id: 7, cat: Audio, desc: "Music"} - - {id: 20, cat: Audio/Video, desc: "Music MVideo"} - - {id: 21, cat: Audio/MP3, desc: "Music MP3"} - - {id: 33, cat: Audio/Video, desc: "Music MP4"} - - {id: 32, cat: Audio/Lossless, desc: "Music FLAC"} - - {id: 2, cat: Console, desc: "Games"} - - {id: 78, cat: PC/Games, desc: "Games Linux"} - - {id: 79, cat: PC/Mac, desc: "Games MacOSX"} - - {id: 28, cat: Console/NDS, desc: "Games Nintendo DS"} - - {id: 27, cat: PC/Games, desc: "Games PC/Windows"} - - {id: 111, cat: Console, desc: "Games Nintendo Sw"} - - {id: 80, cat: Console/PS3, desc: "Games PS2"} - - {id: 81, cat: Console/PS3, desc: "Games PS3"} - - {id: 82, cat: Console/PS4, desc: "Games PS4"} - - {id: 83, cat: Console/PSP, desc: "Games PSP"} - - {id: 95, cat: Console/PS Vita, desc: "Games PSVITA"} - - {id: 4, cat: Console/Wii, desc: "Games Wii"} - - {id: 26, cat: Console/XBox 360, desc: "Games XBOX360"} - - {id: 84, cat: Console/XBox, desc: "Games XONE"} - - {id: 6, cat: PC, desc: "Applications"} - - {id: 18, cat: PC/ISO, desc: "Applications PC"} - - {id: 19, cat: PC/Mac, desc: "Applications Macintosh"} - - {id: 34, cat: PC/ISO, desc: "Applications Linux"} - - {id: 44, cat: Other, desc: "Miscellaneous"} - - {id: 54, cat: Audio/Audiobook, desc: "Books Audiobook"} - - {id: 57, cat: Books/EBook, desc: "Books E-Books"} - - {id: 58, cat: Books/Comics, desc: "Books Comics"} - - {id: 59, cat: Books, desc: "Books Educational"} - - {id: 60, cat: Books/Mags, desc: "Books Magazines/Journals"} - - {id: 61, cat: Other, desc: "Pics/Wallpapers"} - - {id: 62, cat: PC/Mobile-Android, desc: "Android"} - - {id: 63, cat: PC/Mobile-iOS, desc: "iOS"} - - {id: 66, cat: PC/Mobile-Other, desc: "WindowsPhone"} - - {id: 90, cat: Other, desc: "GPS"} - - {id: 68, cat: Other, desc: "Others"} - - {id: 100, cat: Other, desc: "Revistas"} - - {id: 109, cat: TV/Anime, desc: "Anime"} - - {id: 51, cat: TV/Anime, desc: "Anime Filmes"} - - {id: 99, cat: TV/Anime, desc: "Anime Hentai"} - - {id: 110, cat: TV/Anime, desc: "Anime TV Séries"} - - {id: 41, cat: XXX, desc: "XXX"} - - {id: 64, cat: XXX, desc: "XXX-Magazines"} - - {id: 65, cat: XXX, desc: "XXX-Movies"} - - {id: 67, cat: XXX/Pack, desc: "XXX-Packs"} - - {id: 69, cat: XXX/ImageSet, desc: "XXX-Pics/Wallpapers"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info - type: info - label: Layout - default: "
  1. Only the English Classic profile is supported.
  2. Make sure to set the Torrent Listing (Lista de Torrents) option in your profile to Classic (Clássico)
  3. Using the Moderna theme will prevent results.
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you get 0 results check the log for error: An error has occurred!You can't view this page unless you read all your unread messages, then access the site with your browser and mark as read all PMs." - -login: - path: ?p=home&pid=1 - method: form - form: form#loginbox_form - submitpath: /ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: ":contains(\"-ERROR-\")" - test: - path: ?p=torrents&pid=32 - selector: a#logout - -search: - paths: - - path: / - keywordsfilters: - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%25"] - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # name, description, both, uploader - search_type: "{{ if .Query.IMDBID }}description{{ else }}name{{ end }}" - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - error: - - selector: div.error:not(:contains("Não existem resultados encontrados.")):not(:contains("There are no results found.")) - - rows: - selector: table#torrents_table_classic > tbody > tr:has(td.torrent_name) - - fields: - category: - selector: div.category_image > a - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details"] - details: - selector: a[href*="?p=torrents&pid=10&action=details"] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download"] - attribute: href - size: - selector: a[rel="torrent_size"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - grabs: - selector: a[rel="times_completed"] - poster: - selector: a[rel="fancybox"] - attribute: href - imdbid: - # a href="http://tracker.btnext.com/data/torrents/imdb/tt0249677.jpg" - selector: a[href*="/imdb/tt"] - attribute: href - downloadvolumefactor: - case: - "img[title=\"FREE!\"]": 0 - "*": 1 - uploadvolumefactor: - text: 1 - date_ago: - selector: td.torrent_name > abbr.timeago - optional: true - attribute: data-time - date_year: - selector: td.torrent_name:not(:has(abbr.timeago)):not(:contains("day")):not(:contains("Domingo")):not(:contains("Sábado")):not(:contains("Sexta")):not(:contains("Quinta")):not(:contains("Quarta")):not(:contains("Terça")):not(:contains("Segunda")):not(:contains("ontem")):not(:contains("Hoje")) - optional: true - remove: span, a, br - filters: - - name: replace - args: ["Enviado ", ""] # Uploaded - - name: replace - args: ["Uploaded ", ""] - - name: replace - args: [" por", ""] # by - - name: replace - args: [" by", ""] - - name: replace - args: ["às ", ""] # at - - name: replace - args: ["at ", ""] - - name: append - args: " +00:00" # WET - - name: dateparse - args: "dd-MM-yyyy HH:mm zzz" - date_day: - selector: td.torrent_name:not(:has(abbr.timeago)):contains("day"), td.torrent_name:not(:has(abbr.timeago)):contains("Domingo"), td.torrent_name:not(:has(abbr.timeago)):contains("Sábado"), td.torrent_name:not(:has(abbr.timeago)):contains("Quinta"), td.torrent_name:not(:has(abbr.timeago)):contains("Sexta"), td.torrent_name:not(:has(abbr.timeago)):contains("Quarta"), td.torrent_name:not(:has(abbr.timeago)):contains("Terça"), td.torrent_name:not(:has(abbr.timeago)):contains("Segunda"), td.torrent_name:not(:has(abbr.timeago)):contains("ontem"), td.torrent_name:not(:has(abbr.timeago)):contains("Hoje") - optional: true - remove: span, a, br - filters: - - name: replace - args: ["Enviado ", ""] # Uploaded - - name: replace - args: ["Uploaded ", ""] - - name: replace - args: [" por", ""] # by - - name: replace - args: [" by", ""] - - name: replace - args: ["às ", "at "] - - name: replace - args: ["Hoje", "Today"] - - name: replace - args: ["ontem", "Yesterday"] - - name: replace - args: ["Segunda", "Monday"] - - name: replace - args: ["Terça", "Tuesday"] - - name: replace - args: ["Quarta", "Wednesday"] - - name: replace - args: ["Quinta", "Thursday"] - - name: replace - args: ["Sexta", "Friday"] - - name: replace - args: ["Sábado", "Saturday"] - - name: replace - args: ["Domingo", "Sunday"] - - name: fuzzytime - date: - text: "{{ if or .Result.date_year .Result.date_day .Result.date_ago }}{{ or .Result.date_year .Result.date_day .Result.date_ago }}{{ else }}now{{ end }}" -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btschool.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btschool.yml deleted file mode 100644 index 8e2f9980..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btschool.yml +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: btschool -name: BTSCHOOL -description: "BTSCHOOL is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.btschool.club/ - -caps: - categorymappings: - - {id: 405, cat: Movies, desc: "电影/Movies"} - - {id: 406, cat: TV, desc: "连续剧/TV-Series"} - - {id: 407, cat: TV/Anime, desc: "动漫/Animation"} - - {id: 408, cat: TV/Documentary, desc: "纪录片/Documentary"} - - {id: 412, cat: TV, desc: "综艺/TV-Show"} - - {id: 404, cat: PC/0day, desc: "软件/Software"} - - {id: 402, cat: TV, desc: "资料/Education"} - - {id: 411, cat: PC/Games, desc: "游戏/Game"} - - {id: 409, cat: Audio, desc: "音乐/Music"} - - {id: 410, cat: TV/Sport, desc: "体育/Sports"} - - {id: 415, cat: Other, desc: "其他/Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Extreme User or above would never be deleted.
  2. Veteran User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 365 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 30 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("登錄失敗")) - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 justdead - incldead: 0 - # promotion: 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x 50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdb - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: 4 - type: desc - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[title][href^="details.php?id="] - attribute: title - details: - selector: a[title][href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btstate.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btstate.yml deleted file mode 100644 index 4aedbb77..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/btstate.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -id: btstate -replaces: - - btsate -name: btstate -description: "btstate is a Public BitTorrent DHT search engine." -language: en-US -type: public -encoding: UTF-8 -links: - - https://btstate.com/ - -caps: - categories: - Other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - -search: - paths: - # https://btstate.com/api/searchByKeyword/2025?take=100&skip=0&page=1&pageSize=100 - - path: "api/searchByKeyword/{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - response: - type: json - - inputs: - take: 100 - skip: 0 - page: 1 - pageSize: 100 - - rows: - selector: items - attribute: _source - filters: - - name: andmatch - - fields: - category: - text: Other - title: - selector: name - details: - text: "{{ .Config.sitelink }}" - infohash: - selector: infohash - date: - # unix - selector: createTime - filters: - - name: regexp - args: ^(\d{10}) - size: - selector: size - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bwtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bwtorrents.yml deleted file mode 100644 index 014b71f0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/bwtorrents.yml +++ /dev/null @@ -1,241 +0,0 @@ ---- -id: bwtorrents -name: BwTorrents -description: "BwTorrents is an INDIAN Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://bwtorrents.tv/ - - https://www.bwtorrents.pro/ - - https://bwtorrents.cc/ - - https://bwtorrents.live/ -legacylinks: - - https://bwtorrents.fun/ - - https://bwtorrents.xyz/ - - https://bwtorrents.us/ - - https://bwtorrents.co/ - - https://www.bwtorrents.life/ - -caps: - categorymappings: - # note: to update cats for this site its easier to use my.php and sort by cat integer - # also cat 187 is undocumented but exists - - {id: 113, cat: Movies, desc: "Bollywood-Pre-Release"} - - {id: 114, cat: Movies/UHD, desc: "Bollywood-4K Ultra HD / Upscaled"} - - {id: 115, cat: Movies/BluRay, desc: "Bollywood-Untouched BluRay"} - - {id: 116, cat: Movies/HD, desc: "Bollywood-1080p BluRay Rips"} - - {id: 117, cat: Movies/HD, desc: "Bollywood-720p BluRay Rips"} - - {id: 118, cat: Movies, desc: "Bollywood-Remuxes BluRay"} - - {id: 119, cat: Movies/WEB-DL, desc: "Bollywood-Untouched WEB-DLs"} - - {id: 120, cat: Movies/WEB-DL, desc: "Bollywood-1080p WEB-Rips"} - - {id: 121, cat: Movies/DVD, desc: "Bollywood-Untouched DVDs"} - - {id: 122, cat: Movies/WEB-DL, desc: "Bollywood-DVDRips 1080p/720p"} - - {id: 123, cat: Movies/WEB-DL, desc: "Bollywood-SDRips WEB/DVD"} - - {id: 124, cat: Movies/3D, desc: "Bollywood-3D-Movies"} - - {id: 125, cat: Movies/WEB-DL, desc: "Bollywood-Web Series"} - - {id: 126, cat: Movies/UHD, desc: "Hollywood-4K Ultra HD / Upscaled"} - - {id: 127, cat: Movies/BluRay, desc: "Hollywood-Untouched BluRay"} - - {id: 128, cat: Movies/HD, desc: "Hollywood-1080p BluRay Rips"} - - {id: 129, cat: Movies/HD, desc: "Hollywood-720p BluRay Rips"} - - {id: 130, cat: Movies, desc: "Hollywood-BluRay Remuxes"} - - {id: 131, cat: Movies/WEB-DL, desc: "Hollywood-Untouched WEB-DLs"} - - {id: 132, cat: Movies, desc: "Hollywood-1080p WEB-Rips"} - - {id: 133, cat: Movies/DVD, desc: "Hollywood-Untouched DVDs"} - - {id: 134, cat: Movies/WEB-DL, desc: "Hollywood-DVDRips 1080p/720p"} - - {id: 135, cat: Movies/3D, desc: "Hollywood-3D-Movies"} - - {id: 136, cat: Movies, desc: "Hollywood-Pre-Release"} - - {id: 137, cat: Movies, desc: "Malayalam-Movies"} - - {id: 140, cat: Movies, desc: "Punjabi-Movies"} - - {id: 141, cat: Movies, desc: "Kannada-Movies"} - - {id: 142, cat: Movies, desc: "Lollywood-Movies"} - - {id: 143, cat: Movies, desc: "Bhoipuri-Movies"} - - {id: 144, cat: Movies, desc: "Marathi-Movies"} - - {id: 145, cat: Movies, desc: "Bangla-Movies"} - - {id: 146, cat: TV, desc: "TV-Colors"} - - {id: 147, cat: TV, desc: "TV-TV"} - - {id: 148, cat: TV, desc: "TV-Life OK"} - - {id: 149, cat: TV, desc: "TV-Pakistan Drams"} - - {id: 150, cat: TV, desc: "TV-Sab TV"} - - {id: 151, cat: TV, desc: "TV-Sony"} - - {id: 152, cat: TV, desc: "TV-Star Bharat"} - - {id: 153, cat: TV, desc: "TV-Star Plus"} - - {id: 154, cat: TV, desc: "TV-Zee TV"} - - {id: 155, cat: TV/Sport, desc: "TV-Sports"} - - {id: 156, cat: TV/Documentary, desc: "TV-Documentaries"} - - {id: 157, cat: TV, desc: "TV-Hollywood Tv Shows"} - - {id: 158, cat: TV, desc: "TV-Other Tv Shows"} - - {id: 159, cat: Audio, desc: "Music-Religion & Spirituality"} - - {id: 160, cat: Audio, desc: "Music-Classical"} - - {id: 161, cat: Audio/Lossless, desc: "Music-Flacs"} - - {id: 162, cat: Audio, desc: "Music-Ghazals"} - - {id: 163, cat: Audio, desc: "Music-Hindi OSTs"} - - {id: 164, cat: Audio, desc: "Music-Instrumental"} - - {id: 165, cat: Audio, desc: "Music-Kannada"} - - {id: 166, cat: Audio, desc: "Music-Lollywood"} - - {id: 167, cat: Audio, desc: "Music-Malayalam"} - - {id: 168, cat: Audio, desc: "Music-Marathi"} - - {id: 169, cat: Audio/Video, desc: "Music-Videos"} - - {id: 170, cat: Audio, desc: "Music-Pop"} - - {id: 171, cat: Audio, desc: "Music-Punjabi"} - - {id: 172, cat: Audio, desc: "Music-Remix"} - - {id: 173, cat: Audio, desc: "Music-Tamil"} - - {id: 174, cat: Audio, desc: "Music-Telugu"} - - {id: 175, cat: Books/EBook, desc: "Ebooks"} - - {id: 176, cat: PC/Games, desc: "Games PC"} - - {id: 177, cat: Console, desc: "Games Console"} - - {id: 178, cat: TV/Anime, desc: "Anime"} - - {id: 179, cat: PC, desc: "Appz"} - - {id: 180, cat: PC/Mobile-Other, desc: "Mobile Stuff"} - - {id: 181, cat: Other, desc: "Pics/Wallpapers"} - - {id: 182, cat: Movies, desc: "Other Movies"} - - {id: 183, cat: Movies, desc: "English Hindi Dubbed"} - - {id: 184, cat: Movies, desc: "South Hindi Dubbed"} - - {id: 185, cat: Movies, desc: "Gujarati-Movies"} - - {id: 186, cat: TV, desc: "TV-Dangal Tv"} - - {id: 187, cat: XXX, desc: "Adult XXX 18+"} - - {id: 188, cat: Movies/WEB-DL, desc: "Bollywood-720p WEB-Rips"} - - {id: 189, cat: Movies/DVD, desc: "Bollywood-Encoded DVDs"} - - {id: 190, cat: Movies, desc: "Bollywood-Movie packs"} - - {id: 191, cat: Movies/WEB-DL, desc: "Hollywood-Encoded DVDs"} - - {id: 192, cat: Movies, desc: "Hollywood-720p WEB-Rips"} - - {id: 193, cat: Movies/SD, desc: "Hollywood-SDRips - WEB/DVD"} - - {id: 194, cat: Movies, desc: "Hollywood-Movie packs"} - - {id: 195, cat: TV, desc: "TV-Tv Packs"} - - {id: 196, cat: Audio, desc: "Music-Packs"} - - {id: 197, cat: Movies, desc: "Turkish Hindi Dubbed"} - - {id: 198, cat: Movies, desc: "TV-MTV"} - - {id: 199, cat: Movies, desc: "Telgu-Movies | 4K Ultra HD - Upscaled"} - - {id: 200, cat: Movies, desc: "Telgu-Movies | Untouched WEB-DLs"} - - {id: 201, cat: Movies, desc: "Telgu-Movies | 1080p/720p WEBRips"} - - {id: 202, cat: Movies, desc: "Telgu-Movies | Untouched BluRay"} - - {id: 203, cat: Movies, desc: "Telgu-Movies | Untouched DVDs"} - - {id: 204, cat: Movies, desc: "Telgu-Movies | SD-WEBRips / DVDRips"} - - {id: 205, cat: Movies, desc: "Telgu-Movies | Movie Packs"} - - {id: 207, cat: Movies, desc: "Telgu-Movies | BluRay Rips"} - - {id: 208, cat: Movies, desc: "Telgu-Movies | Remuxes BluRay"} - - {id: 209, cat: Movies, desc: "Tamil-Movies | 4K Ultra HD - Upscaled"} - - {id: 210, cat: Movies, desc: "Tamil-Movies | Untouched WEB-DLs"} - - {id: 211, cat: Movies, desc: "Tamil-Movies | 1080p/720p WEBRips"} - - {id: 212, cat: Movies, desc: "Tamil-Movies | Untouched BluRay"} - - {id: 213, cat: Movies, desc: "Tamil-Movies | Untouched DVDs"} - - {id: 214, cat: Movies, desc: "Tamil-Movies | SD-WEBRips / DVDRips"} - - {id: 215, cat: Movies, desc: "Tamil-Movies | Movie Packs"} - - {id: 216, cat: Movies, desc: "Tamil-Movies | BluRay Rips"} - - {id: 217, cat: Movies, desc: "Tamil-Movies | Remuxes BluRay"} - - {id: 218, cat: TV, desc: "TV-Ishara TV"} - - {id: 219, cat: TV, desc: "TV-Bengali Tv"} - - {id: 220, cat: TV, desc: "TV-Shemaroo Umang"} - - {id: 221, cat: TV, desc: "TV-JioTv"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_flaresolverr - type: info_flaresolverr - - name: info_activity - type: info - label: Account Inactivity - default: "Account activity:
  1. Zero activity accounts (0 bytes download and 0 bytes upload) are automatically deleted after 30 days
  2. Inactive accounts under VIP and without parked are automatically deleted after 40 days.
" - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("failed")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://bwtorrents.tv/index.php?search=&blah=0&c120=1&c119=1&incldead=1 - - path: index.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 1 all, 2 onlydead, 3 free - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 name, 1 descr, 2 both - blah: 0 - # does not support imdbid searches and does not have imdb links in results. - - rows: - selector: table[width="1200"] > tbody > tr:has(a[href^="download.php/"]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="index.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] b - details: - selector: a[href^="details.php?id="] - attribute: href - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd-MM-yyyyHH:mm:ss" - size: - selector: td:nth-child(6) - download: - selector: a[href^="download.php/"] - attribute: href - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - case: - "font:contains(\"FreeLeech\")": 0 - "*": 1 - uploadvolumefactor: - case: - img[src="pic/double_upload.png"]: 2 - "*": 1 - genre: - selector: td:nth-child(2) - filters: - - name: regexp - args: "Genre:\\s(.+?)$" - - name: replace - args: [", ", ","] - - name: replace - args: [" ", "_"] - description: - text: "{{ .Result.genre }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 3 day (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# AEON diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/byrbt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/byrbt.yml deleted file mode 100644 index e61c1e08..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/byrbt.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: byrbt -name: BYRBT -description: "BYRBT is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://byr.pt/ - -caps: - categorymappings: - - {id: 408, cat: Movies, desc: "电影Movies"} - - {id: 401, cat: TV, desc: "剧集TV Series"} - - {id: 404, cat: TV/Anime, desc: "动漫Animations"} - - {id: 402, cat: Audio, desc: "音乐Music"} - - {id: 405, cat: Other, desc: "综艺Arts"} - - {id: 403, cat: Console, desc: "游戏Games"} - - {id: 406, cat: PC, desc: "软件Software"} - - {id: 407, cat: TV/Documentary, desc: "资料Documentaries"} - - {id: 409, cat: TV/Sport, desc: "体育Sports"} - - {id: 410, cat: Other/Misc, desc: "纪录Records"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_ipv6 - type: info - label: IPv6 only - default: This site only accepts IPv6 addresses. If you are using IPv4 you will get a DNS Lookup Error. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Newly registered users who have not generated any traffic within 14 days (that is, the upload/download data are both 0) will be deleted
  2. Users with a user level of Peasant that have failed to successfully improve their sharing rate within 14 days will be deleted.
" - -login: - # using cookie method because login page is dynamically generated with JS - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="logout.php?key="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if .Query.DoubanID }}/{{ .Query.DoubanID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact, 3 NOT - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="torrents.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-last-of-type(6) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-last-of-type(6):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-last-of-type(5) - seeders: - selector: td.rowfollow:nth-last-of-type(4) - leechers: - selector: td.rowfollow:nth-last-of-type(3) - grabs: - selector: td.rowfollow:nth-last-of-type(2) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.5 beta 5 20120707 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/byrutor.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/byrutor.yml deleted file mode 100644 index 70af4f7a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/byrutor.yml +++ /dev/null @@ -1,410 +0,0 @@ ---- -id: byrutor -name: Byrutor -description: "Byrutor is a RUSSIAN Public Torrent Tracker for GAMES" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://byrutgame.org/ -legacylinks: - - https://byrutor.org/ - - https://byrutdb.org/ - - https://byrut.org/ - - https://thebyrut.org/ - - https://byruthub.org/ - -caps: - categorymappings: - - {id: 1, cat: PC/Games, desc: "Экшены"} - - {id: 2, cat: PC/Games, desc: "Приключения"} - - {id: 3, cat: PC/Games, desc: "Гонки"} - - {id: 4, cat: PC/Games, desc: "RPG"} - - {id: 5, cat: PC/Games, desc: "Головоломка"} - - {id: 6, cat: PC/Games, desc: "Инди"} - - {id: 7, cat: PC/Games, desc: "Стратегии"} - - {id: 8, cat: PC/Games, desc: "Выживание"} - - {id: 9, cat: PC/Games, desc: "Шутеры"} - - {id: 10, cat: PC/Games, desc: "Симуляторы"} - - {id: 11, cat: PC/Games, desc: "Спортивные"} - - {id: 12, cat: PC/Games, desc: "Аркады"} - - {id: 13, cat: PC/Games, desc: "Файтинги"} - - {id: 14, cat: PC/Games, desc: "Хорроры"} - - {id: 15, cat: PC/Games, desc: "Открытый мир"} - - {id: 16, cat: PC/Games, desc: "Аниме"} - - {id: 248, cat: PC/Games, desc: "Repack от Chovka"} - - {id: 17, cat: PC/Games, desc: "Repack от Механики"} - - {id: 20, cat: PC/Games, desc: "RePack от FitGirl"} - - {id: 18, cat: PC/Games, desc: "Repack от Xatab"} - - {id: 232, cat: PC/Games, desc: "RePack от SpaceX"} - - {id: 233, cat: PC/Games, desc: "RePack от Pioneer"} - - {id: 19, cat: PC/Games, desc: "Repack от Catalyst"} - - {id: 249, cat: PC/Games, desc: "GOG игры"} - - {id: 22, cat: PC/Games, desc: "Русская озвучка"} - - {id: 25, cat: PC/Games, desc: "Старые игры"} - - {id: 26, cat: PC/Games, desc: "Игры с геймпадом"} - - {id: 27, cat: PC/Games, desc: "Мультиплеер"} - - {id: 28, cat: PC/Games, desc: "На двоих"} - - {id: 29, cat: PC/Games, desc: "Рогалик"} - - {id: 30, cat: PC/Games, desc: "Слэшер"} - - {id: 31, cat: PC/Games, desc: "Стелс"} - - {id: 32, cat: PC/Games, desc: "Фэнтези"} - - {id: 33, cat: PC/Games, desc: "Платформер"} - - {id: 34, cat: PC/Games, desc: "Песочница"} - - {id: 35, cat: PC/Games, desc: "Игры в 2D"} - - {id: 36, cat: PC/Games, desc: "Казуальная"} - - {id: 37, cat: PC/Games, desc: "Point and click"} - - {id: 38, cat: PC/Games, desc: "Вид сбоку"} - - {id: 39, cat: PC/Games, desc: "Вид сверху"} - - {id: 40, cat: PC/Games, desc: "Tower Defense"} - - {id: 41, cat: PC/Games, desc: "Экономика"} - - {id: 42, cat: PC/Games, desc: "Тактика"} - - {id: 43, cat: PC/Games, desc: "Пошаговые стратегии (TBS)"} - - {id: 44, cat: PC/Games, desc: "Глобальные стратегии"} - - {id: 45, cat: PC/Games, desc: "Стратегии в реальном времени"} - - {id: 48, cat: PC/Games, desc: "Для взрослых"} - - {id: 49, cat: PC/Games, desc: "От первого лица"} - - {id: 50, cat: PC/Games, desc: "От третьего лица"} - - {id: 51, cat: PC/Games, desc: "Шутер от первого лица"} - - {id: 52, cat: PC/Games, desc: "Шутер от третьего лица"} - - {id: 53, cat: PC/Games, desc: "Игры для слабых ПК"} - - {id: 58, cat: PC/Games, desc: "Психологический хоррор"} - - {id: 59, cat: PC/Games, desc: "Хоррор на выживание"} - - {id: 60, cat: PC/Games, desc: "Ролевой экшен"} - - {id: 61, cat: PC/Games, desc: "Приключенческий экшен"} - - {id: 63, cat: PC/Games, desc: "Головоломка-платформер"} - - {id: 64, cat: PC/Games, desc: "Скролл-шутер"} - - {id: 65, cat: PC/Games, desc: "Шутер с видом сверху"} - - {id: 66, cat: PC/Games, desc: "Градостроительный симулятор"} - - {id: 67, cat: PC/Games, desc: "Космический симулятор"} - - {id: 68, cat: PC/Games, desc: "Научная фантастика"} - - {id: 69, cat: PC/Games, desc: "Ролевая стратегия"} - - {id: 70, cat: PC/Games, desc: "Тактическая ролевая игра"} - - {id: 71, cat: PC/Games, desc: "Тактика в реальном времени"} - - {id: 72, cat: PC/Games, desc: "Японская ролевая игра"} - - {id: 73, cat: PC/Games, desc: "Тёмное фэнтези"} - - {id: 74, cat: PC/Games, desc: "Сложная"} - - {id: 75, cat: PC/Games, desc: "Атмосфера"} - - {id: 76, cat: PC/Games, desc: "Смешная"} - - {id: 77, cat: PC/Games, desc: "Пиксельная графика"} - - {id: 78, cat: PC/Games, desc: "Юмор"} - - {id: 79, cat: PC/Games, desc: "Классика"} - - {id: 80, cat: PC/Games, desc: "Космос"} - - {id: 81, cat: PC/Games, desc: "Зомби"} - - {id: 82, cat: PC/Games, desc: "Мрачная"} - - {id: 83, cat: PC/Games, desc: "Тайна"} - - {id: 84, cat: PC/Games, desc: "Физика"} - - {id: 85, cat: PC/Games, desc: "Реализм"} - - {id: 86, cat: PC/Games, desc: "Короткая"} - - {id: 87, cat: PC/Games, desc: "Строительство"} - - {id: 88, cat: PC/Games, desc: "Война"} - - {id: 89, cat: PC/Games, desc: "Крафтинг"} - - {id: 90, cat: PC/Games, desc: "Будущее"} - - {id: 91, cat: PC/Games, desc: "Средневековье"} - - {id: 92, cat: PC/Games, desc: "Затягивающая"} - - {id: 93, cat: PC/Games, desc: "Строительство базы"} - - {id: 94, cat: PC/Games, desc: "Управление ресурсами"} - - {id: 95, cat: PC/Games, desc: "Насилие"} - - {id: 96, cat: PC/Games, desc: "Для всей семьи"} - - {id: 97, cat: PC/Games, desc: "Исследование"} - - {id: 98, cat: PC/Games, desc: "Расслабляющая"} - - {id: 99, cat: PC/Games, desc: "Реиграбельность"} - - {id: 100, cat: PC/Games, desc: "Поиск предметов"} - - {id: 101, cat: PC/Games, desc: "Пост-апокалипсис"} - - {id: 102, cat: PC/Games, desc: "Вторая мировая война"} - - {id: 103, cat: PC/Games, desc: "Военные действия"} - - {id: 105, cat: PC/Games, desc: "Вождение"} - - {id: 189, cat: PC/Games, desc: "Пошаговая"} - - {id: 106, cat: PC/Games, desc: "Пошаговая тактика"} - - {id: 328, cat: PC/Games, desc: "2D-платформер"} - - {id: 107, cat: PC/Games, desc: "3D-платформер"} - - {id: 108, cat: PC/Games, desc: "Одна жизнь"} - - {id: 109, cat: PC/Games, desc: "Паркур"} - - {id: 110, cat: PC/Games, desc: "Детектив"} - - {id: 111, cat: PC/Games, desc: "Криминал"} - - {id: 112, cat: PC/Games, desc: "Разрушения"} - - {id: 113, cat: PC/Games, desc: "90-е"} - - {id: 114, cat: PC/Games, desc: "80-е"} - - {id: 115, cat: PC/Games, desc: "Разделение экрана"} - - {id: 116, cat: PC/Games, desc: "Военные конфликты"} - - {id: 117, cat: PC/Games, desc: "Драконы"} - - {id: 118, cat: PC/Games, desc: "Пираты"} - - {id: 119, cat: PC/Games, desc: "Ниндзя"} - - {id: 120, cat: PC/Games, desc: "Поезда"} - - {id: 121, cat: PC/Games, desc: "Динозавры"} - - {id: 122, cat: PC/Games, desc: "Раннер"} - - {id: 123, cat: PC/Games, desc: "Супергерои"} - - {id: 124, cat: PC/Games, desc: "По комиксу"} - - {id: 125, cat: PC/Games, desc: "Хакерство"} - - {id: 126, cat: PC/Games, desc: "Эмоциональная"} - - {id: 127, cat: PC/Games, desc: "Футбол"} - - {id: 128, cat: PC/Games, desc: "Рыбалка"} - - {id: 129, cat: PC/Games, desc: "Бездорожье"} - - {id: 130, cat: PC/Games, desc: "Программирование"} - - {id: 131, cat: PC/Games, desc: "Вампиры"} - - {id: 132, cat: PC/Games, desc: "Снайпер"} - - {id: 133, cat: PC/Games, desc: "Ремейк"} - - {id: 134, cat: PC/Games, desc: "Отличный саундтрек"} - - {id: 135, cat: PC/Games, desc: "Глубокий сюжет"} - - {id: 136, cat: PC/Games, desc: "Женщина-протагонист"} - - {id: 137, cat: PC/Games, desc: "Кровь"} - - {id: 138, cat: PC/Games, desc: "Киберпанк"} - - {id: 139, cat: PC/Games, desc: "Эпичная"} - - {id: 140, cat: PC/Games, desc: "Мясо"} - - {id: 141, cat: PC/Games, desc: "Нагота"} - - {id: 142, cat: PC/Games, desc: "Цветастая"} - - {id: 143, cat: PC/Games, desc: "Education"} - - {id: 144, cat: PC/Games, desc: "Менеджмент"} - - {id: 145, cat: PC/Games, desc: "Упрощённый рогалик"} - - {id: 146, cat: PC/Games, desc: "Музыка"} - - {id: 147, cat: PC/Games, desc: "Кастомизация персонажа"} - - {id: 148, cat: PC/Games, desc: "История"} - - {id: 149, cat: PC/Games, desc: "Охота"} - - {id: 150, cat: PC/Games, desc: "По мотивам книги"} - - {id: 151, cat: PC/Games, desc: "Первая мировая война"} - - {id: 152, cat: PC/Games, desc: "Тайм-менеджмент"} - - {id: 153, cat: PC/Games, desc: "Психоделия"} - - {id: 154, cat: PC/Games, desc: "Наука"} - - {id: 155, cat: PC/Games, desc: "Морские бои"} - - {id: 156, cat: PC/Games, desc: "Сельское хозяйство"} - - {id: 157, cat: PC/Games, desc: "Кинематографичная"} - - {id: 158, cat: PC/Games, desc: "Вестерн"} - - {id: 159, cat: PC/Games, desc: "Три в ряд"} - - {id: 160, cat: PC/Games, desc: "Стимпанк"} - - {id: 161, cat: PC/Games, desc: "Редактор уровней"} - - {id: 162, cat: PC/Games, desc: "Культовая классика"} - - {id: 163, cat: PC/Games, desc: "Минимализм"} - - {id: 164, cat: PC/Games, desc: "Shoot'em up"} - - {id: 165, cat: PC/Games, desc: "Решения с последствиями"} - - {id: 166, cat: PC/Games, desc: "Процедурная генерация"} - - {id: 167, cat: PC/Games, desc: "Мультипликация"} - - {id: 168, cat: PC/Games, desc: "Мультфильм"} - - {id: 169, cat: PC/Games, desc: "Пошаговые сражения"} - - {id: 170, cat: PC/Games, desc: "Магия"} - - {id: 171, cat: PC/Games, desc: "Изометрия"} - - {id: 172, cat: PC/Games, desc: "Карточная игра"} - - {id: 173, cat: PC/Games, desc: "Чёрный юмор"} - - {id: 174, cat: PC/Games, desc: "Beat'em up"} - - {id: 175, cat: PC/Games, desc: "Свобода выбора"} - - {id: 176, cat: PC/Games, desc: "Рисованная графика"} - - {id: 177, cat: PC/Games, desc: "Инопланетяне"} - - {id: 178, cat: PC/Games, desc: "Твин-стик шутер"} - - {id: 179, cat: PC/Games, desc: "Настольная игра"} - - {id: 180, cat: PC/Games, desc: "Полёты"} - - {id: 181, cat: PC/Games, desc: "Лут"} - - {id: 183, cat: PC/Games, desc: "MOBA"} - - {id: 184, cat: PC/Games, desc: "Демоны"} - - {id: 185, cat: PC/Games, desc: "Расследование"} - - {id: 186, cat: PC/Games, desc: "Мифология"} - - {id: 187, cat: PC/Games, desc: "VR"} - - {id: 191, cat: PC/Games, desc: "Психологическая"} - - {id: 193, cat: PC/Games, desc: "Подземелье"} - - {id: 196, cat: PC/Games, desc: "Шедевр"} - - {id: 197, cat: PC/Games, desc: "Ретро"} - - {id: 198, cat: PC/Games, desc: "Олдскул"} - - {id: 251, cat: PC/Games, desc: "Визуальная новелла"} - - {id: 252, cat: PC/Games, desc: "Образование"} - - {id: 253, cat: PC/Games, desc: "Партийная RPG"} - - {id: 254, cat: PC/Games, desc: "Симулятор ходьбы"} - - {id: 255, cat: PC/Games, desc: "Симулятор жизни"} - - {id: 256, cat: PC/Games, desc: "Автосимулятор"} - - {id: 257, cat: PC/Games, desc: "Ритм-игра"} - - {id: 258, cat: PC/Games, desc: "Королевская битва"} - - {id: 259, cat: PC/Games, desc: "Симулятор колонии"} - - {id: 260, cat: PC/Games, desc: "Симулятор фермы"} - - {id: 261, cat: PC/Games, desc: "Иммерсивный симулятор"} - - {id: 262, cat: PC/Games, desc: "Кликер"} - - {id: 263, cat: PC/Games, desc: "Арена-шутер"} - - {id: 264, cat: PC/Games, desc: "Платформер на точность"} - - {id: 265, cat: PC/Games, desc: "Метроидвания"} - - {id: 266, cat: PC/Games, desc: "Похожа на Dark Souls"} - - {id: 267, cat: PC/Games, desc: "Коллектатон"} - - {id: 268, cat: PC/Games, desc: "Idle-игра"} - - {id: 269, cat: PC/Games, desc: "Поиск существ"} - - {id: 270, cat: PC/Games, desc: "Зрелищные сражения"} - - {id: 271, cat: PC/Games, desc: "Пасьянс"} - - {id: 272, cat: PC/Games, desc: "Боевые гонки"} - - {id: 273, cat: PC/Games, desc: "Лутер-шутер"} - - {id: 274, cat: PC/Games, desc: "Политический симулятор"} - - {id: 275, cat: PC/Games, desc: "Традиционный рогалик"} - - {id: 276, cat: PC/Games, desc: "Милая"} - - {id: 277, cat: PC/Games, desc: "Стилизация"} - - {id: 278, cat: PC/Games, desc: "Псевдотрёхмерность"} - - {id: 279, cat: PC/Games, desc: "Абстрактная"} - - {id: 280, cat: PC/Games, desc: "Красивая"} - - {id: 281, cat: PC/Games, desc: "Воксельная графика"} - - {id: 282, cat: PC/Games, desc: "Логика"} - - {id: 283, cat: PC/Games, desc: "Драма"} - - {id: 284, cat: PC/Games, desc: "Романтика"} - - {id: 285, cat: PC/Games, desc: "Сюрреалистичная"} - - {id: 286, cat: PC/Games, desc: "Мемы"} - - {id: 287, cat: PC/Games, desc: "Природа"} - - {id: 288, cat: PC/Games, desc: "Роботы"} - - {id: 289, cat: PC/Games, desc: "Проработанная вселенная"} - - {id: 290, cat: PC/Games, desc: "Триллер"} - - {id: 291, cat: PC/Games, desc: "Антиутопия"} - - {id: 292, cat: PC/Games, desc: "Сверхъестественное"} - - {id: 293, cat: PC/Games, desc: "Альтернативная история"} - - {id: 294, cat: PC/Games, desc: "Сражения на мечах"} - - {id: 295, cat: PC/Games, desc: "Котики"} - - {id: 296, cat: PC/Games, desc: "Америка"} - - {id: 297, cat: PC/Games, desc: "Иллюминаты"} - - {id: 298, cat: PC/Games, desc: "Капитализм"} - - {id: 299, cat: PC/Games, desc: "Заговор"} - - {id: 300, cat: PC/Games, desc: "Сатира"} - - {id: 301, cat: PC/Games, desc: "Готика"} - - {id: 303, cat: PC/Games, desc: "Шахты"} - - {id: 304, cat: PC/Games, desc: "Боевые искусства"} - - {id: 305, cat: PC/Games, desc: "Танки"} - - {id: 306, cat: PC/Games, desc: "Подводный мир"} - - {id: 307, cat: PC/Games, desc: "Кулинария"} - - {id: 308, cat: PC/Games, desc: "Флот"} - - {id: 309, cat: PC/Games, desc: "Транспорт"} - - {id: 311, cat: PC/Games, desc: "Марс"} - - {id: 312, cat: PC/Games, desc: "Оборотни"} - - {id: 313, cat: PC/Games, desc: "Бой"} - - {id: 314, cat: PC/Games, desc: "Несколько концовок"} - - {id: 315, cat: PC/Games, desc: "Игра против ИИ"} - - {id: 316, cat: PC/Games, desc: "Линейная"} - - {id: 192, cat: PC/Games, desc: "Нелинейная"} - - {id: 317, cat: PC/Games, desc: "Повествовательная"} - - {id: 318, cat: PC/Games, desc: "Набор очков"} - - {id: 319, cat: PC/Games, desc: "Менеджмент инвентаря"} - - {id: 320, cat: PC/Games, desc: "Перемещение по сетке"} - - {id: 321, cat: PC/Games, desc: "Разделение на классы"} - - {id: 322, cat: PC/Games, desc: "Кастомизация оружия"} - - {id: 323, cat: PC/Games, desc: "Построение колоды"} - - {id: 324, cat: PC/Games, desc: "Гонки на выживание"} - - {id: 325, cat: PC/Games, desc: "Свобода движения"} - - {id: 326, cat: PC/Games, desc: "Замедление времени"} - - {id: 327, cat: PC/Games, desc: "Динамическое повествование"} - - {id: 238, cat: PC/Games, desc: "Игры 2024 года"} - - {id: 237, cat: PC/Games, desc: "Игры 2023 года"} - - {id: 236, cat: PC/Games, desc: "Игры 2022 года"} - - {id: 235, cat: PC/Games, desc: "Игры 2021 года"} - - {id: 199, cat: PC/Games, desc: "Игры 2020 года"} - - {id: 200, cat: PC/Games, desc: "Игры 2019 года"} - - {id: 201, cat: PC/Games, desc: "Игры 2018 года"} - - {id: 202, cat: PC/Games, desc: "Игры 2017 года"} - - {id: 203, cat: PC/Games, desc: "Игры 2016 года"} - - {id: 204, cat: PC/Games, desc: "Игры 2015 года"} - - {id: 205, cat: PC/Games, desc: "Игры 2014 года"} - - {id: 206, cat: PC/Games, desc: "Игры 2013 года"} - - {id: 207, cat: PC/Games, desc: "Игры 2012 года"} - - {id: 208, cat: PC/Games, desc: "Игры 2011 года"} - - {id: 209, cat: PC/Games, desc: "Игры 2010 года"} - - {id: 210, cat: PC/Games, desc: "Игры 2009 года"} - - {id: 211, cat: PC/Games, desc: "Игры 2008 года"} - - {id: 212, cat: PC/Games, desc: "Игры 2007 года"} - - {id: 213, cat: PC/Games, desc: "Игры 2006 года"} - - {id: 214, cat: PC/Games, desc: "Игры 2005 года"} - - {id: 215, cat: PC/Games, desc: "Игры 2004 года"} - - {id: 216, cat: PC/Games, desc: "Игры 2003 года"} - - {id: 217, cat: PC/Games, desc: "Игры 2002 года"} - - {id: 218, cat: PC/Games, desc: "Игры 2001 года"} - - {id: 219, cat: PC/Games, desc: "Игры 2000 года"} - - {id: 220, cat: PC/Games, desc: "Игры 1999 года"} - - {id: 221, cat: PC/Games, desc: "Игры 1998 года"} - - {id: 222, cat: PC/Games, desc: "Игры 1997 года"} - - {id: 223, cat: PC/Games, desc: "Игры 1996 года"} - - {id: 224, cat: PC/Games, desc: "Игры 1995 года"} - - {id: 225, cat: PC/Games, desc: "Игры 1994 года"} - - {id: 226, cat: PC/Games, desc: "Игры 1993 года"} - - {id: 227, cat: PC/Games, desc: "Игры 1992 года"} - - {id: 228, cat: PC/Games, desc: "Игры 1991 года"} - - {id: 229, cat: PC/Games, desc: "Игры 1990 года"} - - {id: 231, cat: PC/Games, desc: "Дополнения"} - - {id: 250, cat: PC/Games, desc: "Модификации"} - - {id: 23, cat: PC/Games, desc: "Ранний Доступ"} - - {id: 246, cat: PC/Games, desc: "Обновление"} - - {id: 230, cat: PC/Games, desc: "Все Топ игры"} - - {id: 329, cat: PC/Games, desc: "Актуальные Топ игры"} - - {id: 24, cat: PC/Games, desc: "Новинка"} - - {id: 194, cat: PC/Games, desc: "Игры по сети"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a.itemdown_games - attribute: href - -search: - # keywords (article titles only search) - # do=search&subaction=search&story=five+freddy&titleonly=3&searchdate=0&sortby=date&resorder=desc&catlist[]=1&catlist[]=2&catlist[]=3 - # keywordless (article body search) query=game - # do=search&subaction=search&story=game&titleonly=0&searchdate=0&sortby=date&resorder=desc&catlist[]=0 - paths: - - path: index.php - inputs: - $raw: "{{ range .Categories }}catlist[]={{.}}&{{end}}" - do: search - subaction: search - search_start: 0 - full_search: 1 - result_from: 1 - showposts: 1 - # 0 article, 1 comments, 2 static pages, 3 article titles - titleonly: "{{ if .Keywords }}3{{ else }}0{{ end }}" - searchdate: 0 - story: "{{ if .Keywords }}{{ .Keywords }}{{ else }}game{{ end }}" - sortby: date - resorder: desc - - rows: - # results without sizes are just pre-release announcements - selector: a.search_res:has(div.search_res_sub span:nth-child(2):contains(" ")) - - fields: - category: - text: 1 - year: - selector: div.search_res_sub span - filters: - - name: regexp - args: (\d+) - title: - selector: a.search_res - filters: - - name: append - args: " [{{ .Result.year }}]" - details: - selector: a.search_res - attribute: href - download: - selector: a.search_res - attribute: href - poster: - selector: img - attribute: src - size_rus: - selector: div.search_res_sub span:nth-child(2):contains("Б") - optional: true - default: "0 B" - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - size: - selector: div.search_res_sub span:nth-child(2):contains("B") - optional: true - default: "{{ .Result.size_rus }}" - date: - text: now - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/c411.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/c411.yml deleted file mode 100644 index 540e47a2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/c411.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: c411 -name: C411 -description: "C411 (Community 411) is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://c411.org/ -legacylinks: - - https://staging-68d548c5bd4.c411.org/ - -caps: - categorymappings: - # from https://c411.org/api/torznab?apikey=YOUR-API-KEY&t=caps - - {id: 1000, cat: Console, desc: "Jeux Vidéo & Emulation & VR"} - - {id: 1030, cat: Console/Wii, desc: "Nintendo"} - - {id: 1040, cat: Console/XBox, desc: "Microsoft"} - - {id: 1080, cat: Console/PS4, desc: "Sony"} - - {id: 1090, cat: Console/Other, desc: "Jeux Autre & ROM/ISO"} - - {id: 2000, cat: Movies, desc: "Films & Vidéos"} - - {id: 2010, cat: Movies/Other, desc: "Films Collection"} - - {id: 2030, cat: Movies/Foreign, desc: "Films Foreign"} - - {id: 2050, cat: Movies/Other, desc: "Vidéo-clips"} - - {id: 2060, cat: Movies/Other, desc: "Films Animation"} - - {id: 2070, cat: Movies, desc: "Films Documentaire"} - - {id: 2080, cat: Movies/Other, desc: "Films Spectacle"} - - {id: 2090, cat: Movies/Other, desc: "Films Concert"} - - {id: 3000, cat: Audio, desc: "Audio"} - - {id: 3010, cat: Audio/MP3, desc: "Musique"} - - {id: 3030, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 3050, cat: Audio/Other, desc: "Karaoké & Podcast Radio & Samples"} - - {id: 4000, cat: PC/0day, desc: "Applications & Windows & Nulled"} - - {id: 4030, cat: PC/0day, desc: "Linux & MacOS"} - - {id: 4040, cat: PC, desc: "Formation"} - - {id: 4050, cat: PC, desc: "Games-Linux & Games-MacOS & Games-Divers & Autre & Émulateur & GPS & Scripts PHP & CMS & Wordpress"} - - {id: 4060, cat: PC/Mobile-Android, desc: "Android Mobile & Smartphone & Tablette & Applications & Cartes"} - - {id: 4070, cat: PC/Mobile-iOS, desc: "iOS Smartphone & Tablette"} - - {id: 5000, cat: TV, desc: "Série TV"} - - {id: 5060, cat: TV/Sport, desc: "Sport"} - - {id: 5070, cat: TV/Anime, desc: "Animation Série"} - - {id: 5080, cat: TV/Documentary, desc: "Emission TV"} - - {id: 6000, cat: XXX, desc: "3X & 3X VR"} - - {id: 6010, cat: XXX/DVD, desc: "3X Films"} - - {id: 6050, cat: XXX/Other, desc: "3X Ebooks"} - - {id: 6060, cat: XXX/ImageSet, desc: "3X Images"} - - {id: 6070, cat: XXX/Other, desc: "Hentai"} - - {id: 6080, cat: XXX/Other, desc: "3X Jeux"} - - {id: 7000, cat: Books, desc: "Ebook"} - - {id: 7010, cat: Books/Mags, desc: "Presse"} - - {id: 7020, cat: Books/EBook, desc: "Livres"} - - {id: 7030, cat: Books/Comics, desc: "BDs & Comics & Manga"} - - {id: 8010, cat: Other/Misc, desc: "Imprimante 3D & Objets & Pack & Personnages"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - allowtvsearchimdb: true - allowrawsearch: true - -settings: - - name: apikey - type: text - label: API Key - - name: info_key - type: info - label: About your API key - default: "You can find or generate a new API Key by accessing the C411 Intégrations API page." - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: info_3x - type: info - label: Including 3X - default: "IF you want to see 3x torrents access your C411 Paramètres page and tick the Afficher le contenu XXX checkbox." - -login: - # returns "401 Unauthorized, check your credentials" if apikey is invalid - path: api/torznab - method: get - inputs: - apikey: "{{ .Config.apikey }}" - t: search - limit: 1 - -search: - paths: - - path: api/torznab - response: - type: xml - - inputs: - apikey: "{{ .Config.apikey }}" - t: "{{ .Query.Type }}" - q: "{{ .Keywords }}" - cat: "{{ join .Categories \",\" }}" - season: "{{ .Query.Season }}" - ep: "{{ .Query.Ep }}" - imdbid: "{{ .Query.IMDBID }}" - tmdbid: "{{ .Query.TMDBID }}" - limit: 100 - - rows: - selector: rss > channel > item - - fields: - category: - selector: '[name="category"]' - attribute: value - title_phase1: - selector: title - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR|VF2|VFF|VFQ|VOQ|VFI|VOF)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: link - download: - selector: enclosure - attribute: url - infohash: - selector: '[name="infohash"]' - attribute: value - imdbid: - selector: '[name="imdbid"]' - attribute: value - tmdbid: - selector: '[name="tmdbid"]' - attribute: value - date: - # Sun, 18 Jan 2026 04:05:41 +0000 - selector: pubDate - filters: - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - size: - selector: size - seeders: - selector: '[name="seeders"]' - attribute: value - leechers: - selector: '[name="peers"]' - attribute: value - grabs: - selector: '[name="grabs"]' - attribute: value - downloadvolumefactor: - selector: '[name="downloadvolumefactor"]' - attribute: value - uploadvolumefactor: - selector: '[name="uploadvolumefactor"]' - attribute: value - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cangbaoge.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cangbaoge.yml deleted file mode 100644 index 7ecadb53..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cangbaoge.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: cangbaoge -name: cangbaoge (CBG) -description: "cangbaoge (CBG) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://cangbao.ge/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 403, cat: TV, desc: "Playlet/短剧"} - - {id: 404, cat: TV/Anime, desc: "Animations/动画"} - - {id: 405, cat: TV/Anime, desc: "Anime/动漫"} - - {id: 406, cat: TV/Other, desc: "Children/儿童"} - - {id: 407, cat: TV, desc: "TV Shows/综艺"} - - {id: 408, cat: TV/Documentary, desc: "Documentary/纪录"} - - {id: 409, cat: Audio, desc: "Music/音乐"} - - {id: 410, cat: Books, desc: "Books/书籍"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/capybarabr.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/capybarabr.yml deleted file mode 100644 index 85ec75c8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/capybarabr.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: capybarabr -name: CapybaraBR (API) -description: "CapybaraBR is a BRAZILIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pt-BR -type: private -encoding: UTF-8 -links: - - https://capybarabr.com/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmes"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 4, cat: TV/Anime, desc: "Animes"} - - {id: 8, cat: TV/Sport, desc: "Esportes"} - - {id: 6, cat: Movies, desc: "Desenhos Animados"} - - {id: 5, cat: Console, desc: "Jogos"} - - {id: 9, cat: PC, desc: "Programas"} - - {id: 10, cat: Books/Comics, desc: "HQs"} - - {id: 11, cat: Books/EBook, desc: "Livros"} - - {id: 12, cat: Other, desc: "Cursos"} - - {id: 13, cat: Books/Mags, desc: "Revistas"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your CapybaraBR account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: use_single_file_release_use_filename - type: checkbox - label: Use filename as the title for single file releases - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "The system automatically makes an account inactive when not logged in for a period of 90 days. So just log in within this time so that it is not inactivated." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.use_single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Ficção científica)", "Ficção_científica"] - - name: re_replace - args: ["(?i)(Cinema TV)", "Cinema_TV"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " -03:00" # BRT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 1.0 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carpathians.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carpathians.yml deleted file mode 100644 index 0ab81004..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carpathians.yml +++ /dev/null @@ -1,169 +0,0 @@ ---- -id: carpathians -name: Carpathians -description: "Carpathians is a HUNGARIAN Private Torrent Tracker for 0DAY / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://mycarpathians.net/ - -caps: - categorymappings: - - {id: 186, cat: Books/EBook, desc: "E-book"} - - {id: 199, cat: Movies/SD, desc: "Film/Bluray/Hun"} - - {id: 194, cat: Movies/SD, desc: "Film/CAM"} - - {id: 60, cat: Movies/DVD, desc: "Film/DVD-R/Eng"} - - {id: 10, cat: Movies/DVD, desc: "Film/DVD-R/Hun"} - - {id: 20, cat: Movies/DVD, desc: "Film/DVD9/Hun"} - - {id: 181, cat: Movies/HD, desc: "Film/HD/Eng"} - - {id: 183, cat: Movies/HD, desc: "Film/HD/Hun"} - - {id: 200, cat: Movies/UHD, desc: "Film/UHD/Hun"} - - {id: 192, cat: Movies/HD, desc: "Film/x264/Eng"} - - {id: 190, cat: Movies/HD, desc: "Film/x264/Hun"} - - {id: 70, cat: Movies/SD, desc: "Film/XviD/Eng"} - - {id: 30, cat: Movies/SD, desc: "Film/XviD/Hun"} - - {id: 150, cat: Console, desc: "Játék/Konzol"} - - {id: 130, cat: PC/Games, desc: "Játék/PC"} - - {id: 182, cat: Audio/Lossless, desc: "Lossless"} - - {id: 90, cat: Audio/MP3, desc: "Mp3/Eng"} - - {id: 50, cat: Audio/MP3, desc: "Mp3/Hun"} - - {id: 100, cat: Audio/Video, desc: "Music-Video"} - - {id: 180, cat: PC/Mobile-Other, desc: "Program/Mobil"} - - {id: 170, cat: PC/0day, desc: "Program/PC"} - - {id: 80, cat: TV, desc: "Sorozat/Eng"} - - {id: 40, cat: TV, desc: "Sorozat/Hun"} - - {id: 110, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 5 - options: - 5: created - 8: seeders - 6: size - 2: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table.main:contains("Sikertelen bejelentkezés") - test: - path: index.php - selector: a[href^="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}https://www.imdb.com/title/{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid search - # 0 both, 1 only active, 2 only dead - incldead: 0 - onlyname: 1 - onlyname2: true - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # can search by genre but you need the id. &imdbcat=1 for Akció. - - rows: - selector: table[id!="torrent_ajanlo"] > tbody > tr[id] - - fields: - download: - selector: a[href^="download.php/"] - attribute: href - title: - selector: a[id][href^="details.php?"] - attribute: title - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - details: - selector: a[id][href^="details.php?"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: a[rel^="prettyPhoto["] - attribute: href - genre: - selector: font.line - filters: - - name: validate - args: "Akció, Animáció, Beszélgetős, Családi, Dokumentum, Dráma, Életrajzi, Fantasztikus, Film-Noir, Game-Show, Háborús, Horror, Kaland, Krimi, Misztikus, Musical, News, Reality-TV, Romantikus, Rövidfilm, Sci-Fi, Sport, Thriller, Történelmi, Vígjáték, Western, Zene" - description: - text: "{{ .Result.genre }}" - files: - selector: td:nth-child(4) - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - date: - selector: font.line - filters: - - name: split - args: ["|", 0] - - name: replace - args: ["Feltöltve: ", ""] - - name: replace - args: ["Feltöltve : ", ""] - - name: replace - args: ["perce", "minute"] - - name: replace - args: ["órája", "hour"] - - name: replace - args: ["napja", "day"] - - name: replace - args: ["hete", "week"] - - name: replace - args: ["éve", "year"] - - name: timeago - downloadvolumefactor: - text: 0 - uploadvolumefactor: - case: - img[src="pic/double.png"]: 2 - img[src="pic/doubledouble2.png"]: 4 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TBDev & XBTT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carphunter.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carphunter.yml deleted file mode 100644 index 027f0063..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carphunter.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: carphunter -name: Carp-Hunter -description: "Carp-Hunter is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://carp-hunter.hu/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 1, cat: Movies/SD, desc: "Film/Cam/Eng", default: true} - - {id: 2, cat: Movies/SD, desc: "Film/Cam/Hun", default: true} - - {id: 8, cat: Movies/DVD, desc: "Film/DVD/Eng", default: true} - - {id: 7, cat: Movies/DVD, desc: "Film/DVD/Hun", default: true} - - {id: 16, cat: Movies/HD, desc: "Film/HD1080P/Eng", default: true} - - {id: 15, cat: Movies/HD, desc: "Film/HD1080P/Hun", default: true} - - {id: 14, cat: Movies/HD, desc: "Film/HD720P/Eng", default: true} - - {id: 13, cat: Movies/HD, desc: "Film/HD720P/Hun", default: true} - - {id: 4, cat: Movies/SD, desc: "Film/SD/Eng", default: true} - - {id: 3, cat: Movies/SD, desc: "Film/SD/Hun", default: true} - - {id: 18, cat: Movies/UHD, desc: "Film/UHD/Eng", default: true} - - {id: 17, cat: Movies/UHD, desc: "Film/UHD/Hun", default: true} - - {id: 23, cat: Audio/Audiobook, desc: "Hangosköny", default: true} - - {id: 19, cat: PC/Games, desc: "Játék/ISO", default: true} - - {id: 20, cat: PC/Games, desc: "Játék/RIP", default: true} - - {id: 24, cat: Other, desc: "Képek", default: true} - - {id: 21, cat: Books, desc: "Könyv/Eng", default: true} - - {id: 22, cat: Books, desc: "Könyv/Hun", default: true} - - {id: 35, cat: TV/Anime, desc: "Mese/Eng", default: true} - - {id: 36, cat: TV/Anime, desc: "Mese/Hun", default: true} - - {id: 25, cat: PC/Mobile-Other, desc: "Mobil", default: true} - - {id: 30, cat: PC/ISO, desc: "Programok/ISO", default: true} - - {id: 31, cat: PC/0day, desc: "Programok/RIP", default: true} - - {id: 33, cat: TV, desc: "Sorozat/Eng", default: true} - - {id: 32, cat: TV, desc: "Sorozat/Hun", default: true} - - {id: 29, cat: Audio/Lossless, desc: "Zene/FLAC/Eng", default: true} - - {id: 28, cat: Audio/Lossless, desc: "Zene/FLAC/Hun", default: true} - - {id: 27, cat: Audio/MP3, desc: "Zene/MP3/Eng", default: true} - - {id: 26, cat: Audio/MP3, desc: "Zene/MP3/Hun", default: true} - - {id: 34, cat: XXX, desc: "XXX/Filmek", default: false} - - {id: 39, cat: XXX/ImageSet, desc: "XXX/Képek", default: false} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_settings - type: info - label: Settings Requirement - default: This indexer only supports the details link option.
Navigate to the web site account Beállítások (Settings), change the Letöltések (Download) option to Link a részletekhez (Link to Details), and click Mentés (Save). Selecting Lenyíló menü (Dropdown Menu) will return no results. - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="logout.php"] - -search: - paths: - - path: browse.php - categories: [1, 2, 8, 7, 16, 15, 14, 13, 4, 3, 18, 17, 23, 19, 20, 24, 21, 22, 35, 36, 25, 30, 31, 33, 32, 29, 28, 27, 26] - - path: browse.php - categories: [34, 39] - inputs: - xxx: 1 - keywordsfilters: - # the site uses % for wildcard - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%"] - inputs: - $raw: "{{ range .Categories }}cat[]={{.}}&{{end}}" - search: "{{ .Keywords }}" - # 0 both, 1 onlyactive, 2 onlydead, 3 onlyfree, 4 x2 torrents, 5 mytorrents, 6 waiting for seeds - incldead: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # does not support imdbid searches - # can support genre searching but you need to know the id, eg &genre2=1 for Comedy (id is 2) - - rows: - selector: "table[cellpadding=\"3\"][style=\"width: 920px;\"] > tbody > tr:has(a[href^=\"browse.php?cat=\"])" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href*="/details.php?id="] - attribute: title - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/details.php?id="] - attribute: href - filters: - - name: replace - args: ["/details", "/download"] - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: small[title] - attribute: title - description: - case: - img[src="/pic/yes.png"]: "Verified: {{ .Result.genre }}" - img[src="/pic/nincs.png"]: "Unverified: {{ .Result.genre }}" - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "showTorrentThumb\\('(.*?)', " - date: - selector: td:nth-last-child(6) - filters: - - name: regexp - args: (\d{4}-\d{2}-\d{2}.\d{2}:\d{2}:\d{2}) - - name: replace - args: ["\u00a0", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - font:contains("x0"): 0 - "*": 1 - uploadvolumefactor: - case: - font:contains("x2"): 2 - font:contains("x4"): 4 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carpt.yml deleted file mode 100644 index d3c1493b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/carpt.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: carpt -name: CarPT -description: "CarPT is a CHINESE Private Torrent Tracker for HD MOVIES / TV / MUSIC" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://carpt.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/连续剧"} - - {id: 403, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV, desc: "TV Shows/综艺"} - - {id: 406, cat: Audio, desc: "Music/音乐"} - - {id: 407, cat: Other, desc: "Misc/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the CarPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account Retention Rules:
  1. Extreme User and above will be retained forever
  2. Elite Users and above will not be banned after parking (in the Control Panel)
  3. Users with a parked account will be banned if they do not log in for 150 consecutive days
  4. Users with a non-parked account will be banned if they do not log in for 90 consecutive days
  5. Users with no traffic (i.e., upload/download data is 0) who do not log in for 14 consecutive days will be banned.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 10 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.15 2024-12-10 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cathoderaytube.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cathoderaytube.yml deleted file mode 100644 index 044ae1c2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cathoderaytube.yml +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: cathoderaytube -name: Cathode-Ray.Tube -description: "Cathode-Ray.Tube (CRT) is a Private Torrent Tracker for CLASSIC MOVIES / TV" -language: en-GB -type: private -encoding: UTF-8 -links: - - https://www.cathode-ray.tube/ - -caps: - categorymappings: - - {id: 13, cat: PC/Games, desc: "Games"} - - {id: 4, cat: Other, desc: "Misc"} - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Other, desc: "WOC"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, genre] - movie-search: [q, imdbid, genre] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the CRT2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "New members must generate some torrent activity (upload, download, or cross-seed) in their account within the first 30 days of joining, otherwise their account will be automatically disabled." - -login: - path: login - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cinfo: "2550|1350|1.25|24|-60" - iplocked: 0 - keeploggedin: 1 - error: - - selector: div.error - test: - path: / - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}" - title: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ end }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - taglist: "{{ .Query.Genre }}" - - keywordsfilters: - - name: re_replace - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1"] - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - category: - selector: a[href^="/torrents.php?filter_cat"] - attribute: href - filters: - - name: regexp - args: \[(\d+)\] - title: - selector: a[href^="/torrents.php?id="] - filters: - - name: re_replace # Seasons X-Y --> S0X-S0Y - args: ["(?i)\\bSeasons?\\s*(\\d)\\s*-\\s*(\\d)\\b", "S0$1-S0$2"] - - name: re_replace # Seasons X-YY --> S0X-SYY - args: ["(?i)\\bSeasons?\\s*(\\d)\\s*-\\s*(\\d+)\\b", "S0$1-S$2"] - - name: re_replace # Seasons XX-YY --> SXX-SYY - args: ["(?i)\\bSeasons?\\s*(\\d+)\\s*-\\s*(\\d+)\\b", "S$1-S$2"] - - name: re_replace # Season X --> S0X - args: ["(?i)\\bSeasons?\\s*(\\d)\\b", "S0$1"] - - name: re_replace # Season XX --> SXX - args: ["(?i)\\bSeasons?\\s*(\\d+)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\bComplete\\s+Series\\b", ""] - details: - selector: a[href^="/torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - genre: - selector: div.tags - filters: - - name: validate - args: "Action, Adventure, Animation, Comedy, Crime, Documentary, Drama, Family, Fantasy, History, Horror, Kids, Music, Mystery, News, Reality, Romance, SciFi, Soap, Talk, Thriller, War, Western" - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/styles/modern/crt.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 1.0 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/catorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/catorrent.yml deleted file mode 100644 index 7857fd86..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/catorrent.yml +++ /dev/null @@ -1,91 +0,0 @@ ---- -id: catorrent -name: Catorrent -description: "Catorrent is a RUSSIAN Public Torrent Tracker for GAMES" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://catorrent.net/ -legacylinks: - - https://catorrent.org/ - -caps: - categorymappings: - - {id: PC, cat: PC/Games, desc: "PC"} - - {id: PS1, cat: Console/Other, desc: "Playstation 1"} - - {id: PS2, cat: Console/Other, desc: "Playstation 2"} - - {id: PS3, cat: Console/PS3, desc: "Playstation 3"} - - {id: PS4, cat: Console/PS4, desc: "Playstation 4"} - - {id: PS5, cat: Console/Other, desc: "Playstation 5"} - - {id: SWITCH, cat: Console/Other, desc: "Nintendo Switch"} - - {id: PSP, cat: Console/PSP, desc: "Playstation (portable) PSP"} - - {id: PSVITA, cat: Console/PS Vita, desc: "Playstation Vita"} - - {id: XONE, cat: Console/XBox One, desc: "Xbox ONE"} - - {id: X360, cat: Console/XBox 360, desc: "Xbox 360"} - - {id: WIIU, cat: Console/WiiU, desc: "Nintendo WIIU"} - - {id: WII, cat: Console/Wii, desc: "Nintendo WII"} - - {id: 3DS, cat: Console/3DS, desc: "Nintendo 3DS"} - - {id: NDS, cat: Console/NDS, desc: "Nintendo DS"} - - {id: N64, cat: Console/Other, desc: "Nintendo 64"} - - {id: NGC, cat: Console/Other, desc: "Nintendo Game Cube"} - - {id: DREAMCAST, cat: Console/Other, desc: "Dreamcast"} - - {id: SAT, cat: Console/Other, desc: "Sega Saturn"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[href*="/index.php?do=download&id="] - attribute: href - -search: - # https://catorrent.org/index.php?do=search&subaction=search&story=lovelot - paths: - - path: index.php - inputs: - do: search - subaction: search - search_start: 0 - result_from: 1 - story: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - - rows: - selector: div.short_game_search - - fields: - category: - selector: div.icon_default - optional: true - default: PS1 - title: - selector: div.short_game_title - details: - selector: a[href$=".html"] - attribute: href - download: - selector: a[href$=".html"] - attribute: href - poster: - selector: img - attribute: src - genre: - selector: div.short_game_genre - description: - text: "{{ .Result.genre }}" - date: - text: now - seeders: - text: 1 - leechers: - text: 1 - size: - text: "512 MB" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# DataLife v25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cdfile.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cdfile.yml deleted file mode 100644 index da3792da..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cdfile.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: cdfile -name: CD File -description: "CD File is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.cdfile.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/MV"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 408, cat: Audio, desc: "Music/音轨"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 409, cat: Other, desc: "Others/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 180 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 90 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 7 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ceskeforum.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ceskeforum.yml deleted file mode 100644 index 61563b70..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ceskeforum.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: ceskeforum -name: CeskeForum -description: "CeskeForum is a CZECH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: cs-CZ -type: private -encoding: UTF-8 -links: - - https://t.ceskeforum.com/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Filmy (Movies)"} - - {id: 403, cat: TV, desc: "TV pořady (TV Shows)"} - - {id: 404, cat: TV/Documentary, desc: "TV dokumenty (Documentaries)"} - - {id: 408, cat: TV, desc: "TV seriály - kompletní série (TV Series complete)"} - - {id: 402, cat: TV, desc: "TV seriály - jednotlivé díly (TV Series episodes)"} - - {id: 407, cat: TV/Sport, desc: "Sport"} - - {id: 411, cat: Books, desc: "Knihy (Books)"} - - {id: 413, cat: Audio/Audiobook, desc: "Knihy ve zvukové podobě (AudioBooks)"} - - {id: 406, cat: Audio, desc: "Hudba (Music)"} - - {id: 410, cat: PC, desc: "Software"} - - {id: 412, cat: PC/Games, desc: "Software - Hry (Games)"} - - {id: 409, cat: Other, desc: "Nezařazené (Misc)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("Přihlášení selhalo!")) - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0=incldead, 1=active, 2=dead - incldead: 0 - # show promotions: 0=all, 1=normal, 2=free, 3=2x, 4=2xFree, 5=50%, 6=2x50%, 7=30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0=title, 1=descr, 3=uploader, 4=imdb URL - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0=AND, 1=OR, 2=Exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # can support genre searching but you need to know the id, eg &team10=1 for Drama (id is 10) - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - genre: - selector: table.torrentname > tbody > tr > td:first-child - remove: a - filters: - - name: validate - args: "Akční, Animovaný, Dobrodružný, Dokumentární, Drama, Fantasy, Horor, Komedie, Krimi, Pohádka, Rodinný, Romantický, Sci-Fi, Thriller, Válečný, Western" -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cgpeers.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cgpeers.yml deleted file mode 100644 index 5106f5c0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cgpeers.yml +++ /dev/null @@ -1,126 +0,0 @@ ---- -id: cgpeers -name: CGPeers -description: "CGPeers is a Private Torrent Tracker for GRAPHICS SOFTWARE / TUTORIALS / ETC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://cgpeers.to/ -legacylinks: - - https://www.cgpeers.com/ - -caps: - categorymappings: - - {id: 17, cat: Other, desc: "Web Development"} - - {id: 1, cat: PC/ISO, desc: "Applications"} - - {id: 8, cat: PC/ISO, desc: "Audio"} - - {id: 7, cat: Other, desc: "Game Development"} - - {id: 5, cat: Other, desc: "Materials"} - - {id: 6, cat: Other/Misc, desc: "Miscellaneous"} - - {id: 4, cat: Other, desc: "Models"} - - {id: 2, cat: PC/0day, desc: "Plugins"} - - {id: 3, cat: Other, desc: "Tutorials"} - - {id: 9, cat: Other, desc: "Web Development"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a.quick-action-logout - -search: - paths: - - path: torrent/browse - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - title: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - rows: - selector: table#torrent_table > tbody > tr:has(a[href*="torrent/download/"]) - - fields: - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "(\\d+)]=1" - title: - selector: td:nth-child(2) div a[href*="torrent/"] - details: - selector: td:nth-child(2) div a[href*="torrent/"] - attribute: href - download: - selector: a[href*="torrent/download/"] - attribute: href - poster: - selector: td:nth-child(2) div a[href*="torrent/"] - attribute: data-bs-content - filters: - - name: regexp - args: src=\"(.*?)\" - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(6) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - i.unlimited_leech: 0 - i.sitewide_leech: 0 - img[alt="Freeleech"]: 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - img[alt="DoubleSeed"]: 2 - "*": 1 -# CGPeers cc1d0762 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/chdbits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/chdbits.yml deleted file mode 100644 index 8ab1854c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/chdbits.yml +++ /dev/null @@ -1,183 +0,0 @@ ---- -id: chdbits -name: CHDBits -description: "CHDBits is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ptchdbits.co/ - - https://chdbits.co/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries"} - - {id: 405, cat: TV/Anime, desc: "Animations"} - - {id: 402, cat: TV, desc: "TV Series"} - - {id: 403, cat: TV, desc: "TV Shows"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - {id: 407, cat: TV/Sport, desc: "Sports"} - - {id: 409, cat: Other, desc: "Misc"} - - {id: 408, cat: Audio, desc: "HQ Audio"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - headers: - user-agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - - selector: td.embedded:has(h2:contains("failed")) - - selector: td.embedded:has(h2:contains("错误")) - - selector: td.embedded:has(h2:contains("error")) - - selector: font[color="red"] - test: - path: torrents.php - selector: a[href*="logout"] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - incldead: 1 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-last-child(7) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-last-child(7):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-last-child(6) - seeders: - selector: td.rowfollow:nth-last-child(5) - leechers: - selector: td.rowfollow:nth-last-child(4) - grabs: - selector: td.rowfollow:nth-last-child(3) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 (customised) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/chilebt-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/chilebt-api.yml deleted file mode 100644 index 3a713536..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/chilebt-api.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -id: chilebt-api -name: ChileBT (API) -description: "ChileBT is a CHILEAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: es-CL -type: private -encoding: UTF-8 -links: - - https://chilebt.com/ - -caps: - categorymappings: - - {id: 10, cat: Movies, desc: "Movies"} - - {id: 11, cat: TV, desc: "TV"} - - {id: 9, cat: Audio, desc: "Music"} - - {id: 7, cat: Other, desc: "Cultura / Educativos"} - - {id: 8, cat: PC/Games, desc: "Juegos"} - - {id: 6, cat: Books, desc: "EBook"} - - {id: 4, cat: TV/Anime, desc: "Anime"} - - {id: 5, cat: PC/0day, desc: "Aplicaciones / Programas"} - - {id: 14, cat: Movies, desc: "Infantil"} - - {id: 12, cat: Other/Misc, desc: "VIP"} - - {id: 13, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your ChileBT account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - 0: "{{ .False }}" - 1: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.5 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.5 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 8.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinemageddon.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinemageddon.yml deleted file mode 100644 index a952ba4e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinemageddon.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -id: cinemageddon -name: Cinemageddon -description: "Cinemageddon is a Private Torrent Tracker for B-movies" -language: en-US -type: private -encoding: UTF-8 -links: - - https://cinemageddon.net/ -legacylinks: - - http://cinemageddon.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Action"} - - {id: 2, cat: Movies, desc: "Horror"} - - {id: 3, cat: Movies, desc: "Martial Arts"} - - {id: 4, cat: Movies, desc: "Comedy"} - - {id: 5, cat: Movies, desc: "Other"} - - {id: 6, cat: Movies, desc: "Hidden Gems"} - - {id: 7, cat: Movies, desc: "Sci-Fi"} - - {id: 8, cat: Movies, desc: "Gore"} - - {id: 9, cat: Movies, desc: "Exploitation"} - - {id: 11, cat: Movies, desc: "OST"} - - {id: 12, cat: Movies, desc: "XXX"} - - {id: 13, cat: Movies, desc: "Thriller"} - - {id: 14, cat: Movies, desc: "Adventure"} - - {id: 15, cat: Movies, desc: "Documentary"} - - {id: 16, cat: Movies, desc: "Western"} - - {id: 17, cat: Movies, desc: "Family"} - - {id: 18, cat: Movies, desc: "Drama"} - - {id: 19, cat: Movies, desc: "Ebooks"} - - {id: 20, cat: Movies, desc: "Softcore"} - - {id: 21, cat: Movies, desc: "Tinfoil Hat"} - - {id: 22, cat: Movies, desc: "Trailers"} - - modes: - search: [q] - movie-search: [q, imdbid] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table:contains("Login failed!") - test: - path: index.php - selector: a[href$="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - - rows: - selector: table.torrenttable > tbody > tr:has(a[href*="browse.php?cat="]) - - fields: - category: - selector: a[href*="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href*="details.php?id="] - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(4) - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(5) - filters: - - name: regexp - args: (\d+.*(MB|GB)+) - files: - selector: td:nth-child(5) - filters: - - name: regexp - args: (\d+)\s+file - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src$="/golden10.gif"]: 0 # freeleech - img[src$="/golden1.gif"]: 0.9 # grey coin possibly 10% discount - img[src$="/golden2.gif"]: 0.8 # bronze coin 20% discount - img[src$="/golden3.gif"]: 0.7 # siver coin 30% discount - img[src$="/golden4.gif"]: 0.6 # gold coin 40% discount - "*": 1 - uploadvolumefactor: - case: - img[src$="/golden10.gif"]: 2 # freeleech 2x bonus - img[src$="/golden1.gif"]: 1.1 # gray coin possibly 10% bonus - img[src$="/golden2.gif"]: 1.2 # bronze coin 20% bonus - img[src$="/golden3.gif"]: 1.3 # silver coin 30% bonus - img[src$="/golden4.gif"]: 1.4 # gold coin 40% bonus - "*": 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinemamovies.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinemamovies.yml deleted file mode 100644 index 2ab47d59..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinemamovies.yml +++ /dev/null @@ -1,198 +0,0 @@ ---- -id: cinemamovies -name: CinemaMovieS_ZT -description: "CinemaMovieS_ZT is a POLISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: private -encoding: ISO-8859-2 -links: - - https://cinemamovies.pl/ - -caps: - categorymappings: - - {id: 3, cat: Movies/BluRay, desc: "Filmy BD25/50_5/9"} - - {id: 5, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 6, cat: Movies/DVD, desc: "Filmy DVD5/9"} - - {id: 8, cat: Movies/HD, desc: "Filmy HD"} - - {id: 11, cat: Movies/SD, desc: "Filmy SD"} - - {id: 12, cat: Movies/Other, desc: "Filmy Inne"} - - {id: 32, cat: Movies/UHD, desc: "Filmy UHD 4K"} - - {id: 33, cat: TV/UHD, desc: "TV UHD"} - - {id: 2, cat: TV/Anime, desc: "Bajki/Anime"} - - {id: 10, cat: TV/Documentary, desc: "TV Dokumentalne"} - - {id: 20, cat: TV, desc: "TV Paczki"} - - {id: 22, cat: TV/Sport, desc: "TV Sport"} - - {id: 24, cat: TV/HD, desc: "TV HD"} - - {id: 25, cat: TV/SD, desc: "TV SD"} - - {id: 1, cat: PC, desc: "Aplikacje"} - - {id: 15, cat: PC/Games, desc: "Gry"} - - {id: 16, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 17, cat: Other, desc: "Inne"} - - {id: 19, cat: Audio, desc: "Muzyka"} - - {id: 21, cat: Books, desc: "Book"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: drop_polish_prefix - type: checkbox - label: Drop the Polish title prefix - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - - name: sort - type: select - label: Sort requested from site - default: 2 - options: - 2: created - 5: seeders - 3: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Failure to use an account for a period of 180 days will result in the removal of the account from the CMS_ZT community." - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 all, 4 requests, 5 onlydead, 6 polish, 8 free, 10 doubleup, 11 premier, 13 VOD - incldead: "{{ if .Config.freeleech }}8{{ else }}1{{ end }}" - # 0 title, 1 descr - blah: "{{ if .Query.IMDBID }}1{{ else }}0{{ end }}" - gatunek: 0 - quality: none - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # can search by a genre, (gatunek), but you need to know the id: &gatunek=64 to search Sci-Fi - - rows: - selector: table[width="100%"] > tbody > tr:has(a[href^="browse.php?cat="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_raw: - selector: a[href^="details.php?id="] - title_multilang: - text: "{{ .Result.title_raw }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title_phase1: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_raw }}{{ end }}" - title_stripped: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["^(.* / )(.*)$", "$2"] - title: - text: "{{ if .Config.drop_polish_prefix }}{{ .Result.title_stripped }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - poster: - selector: a[href^="details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: td[align="right"][width="66%"][valign="bottom"] - filters: - - name: replace - args: ["\xA0", " "] - - name: validate - args: "Akcja, Animacja, Anime, Basn, Biblijny, Biograficzny, Czarna komedia, Dla dzieci, Dla mlodziezy, Dokumentalizowany, Dokumentalny, Dramat, Dramat historyczny, Dramat obyczajowy, Dramat sadowy, Dramat spoleczny, Dreszczowiec, Edukacyjny, Erotyczny, Etiuda, Fabularyzowany dok., Familijny, Fantasy, Film-Noir, Gangsterski, Groteska filmowa, Historyczny, Horror, Karate, Katastroficzny, Komedia, Komedia dokumentalna, Komedia kryminalna, Komedia obycz., Komedia rom., Kostiumowy, Krótkometrażowy, Kryminał, Melodramat, Motoryzacyjny, Musical, Muzyczny, Kulinarny, Nowele filmowe, Obyczajowy, Poetycki, Polityczny, Prawniczy, Przygodowy, Przyrodniczy, Psychologiczny, Plaszcza i szpady, Religijny, Romans, Rozrywka, Satyra, Sci-Fi, Sensacyjny, Sportowy, Surreallistyczny, Szpiegowski, Sztuki walki, Świąteczne, Thriller, Western, Wojenny, Kabaret, Action, Adventure, Arcade, Fps, Fighting, MMO, Puzzle, Racing, Rpg, RTS, Shooter, Simulation, Stealth, Strategy, Sport , Tps, Early Access, DOX, PC, X360, PlayStation, PSP, Alternative, Ambient, Classical, Dubstep, Drum and bass, Dance, Disco polo, Disco, Dla dzieci, Electronic, Folc, Hardstyle, Hardcore, Hip-hop, House, Jazz, Kompilacje muzyczne, Metal, Progressive house, Pop, Punk, Rap, Reggae, Rnb, Rock, Soul, Techno, Trance, Breaks, Chill out, Club, Deep house, Electro house, Tech house, GOA/PSY, Instrumental, Minimal, Soundtrack, Sety, Prasa, Czasopisma, Książka, Audiobook, Polskie, Klasyka, Poezja, Literatura piękna, Literatura faktu, Autobiografia, Pamiętnik" - description: - selector: img[src="pic/Poland.png"] - attribute: src - filters: - - name: prepend - args: "{{ if .Result.genre }}{{ .Result.genre }} - {{ else }}{{ end }}" - - name: replace - args: ["pic/Poland.png", "Polish"] - date: - selector: td[width="66%"] > span > span - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-child(4) - seeders: - selector: a[href$="#seedy"] - leechers: - selector: a[href$="#leechy"] - grabs: - selector: td:last-child > small> span - downloadvolumefactor: - case: - img[src="pic/free.png"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="pic/double.png"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinematik.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinematik.yml deleted file mode 100644 index ebe3fa13..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cinematik.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: cinematik -name: Cinematik (API) -description: "Cinematik is a Private tracker for full BD and DVD discs of non-mainstream movies, niche cinema and arthouse." -language: en-US -type: private -encoding: UTF-8 -links: - - https://cinematik.net/ -legacylinks: - - https://www.cinematik.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Film"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Movies/Foreign, desc: "Foreign Film"} - - {id: 4, cat: TV/Foreign, desc: "Foreign TV"} - - {id: 5, cat: Movies/Other, desc: "Opera & Musical"} - - {id: 6, cat: Movies/Foreign, desc: "Asian Film"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Cinematik account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "With 2FA enabled, there are no specific requirements for uploading, downloading, or posting. Your membership continues based on the staff's fair assessment of your overall activity and contributions." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.6 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.6 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/clearjav-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/clearjav-api.yml deleted file mode 100644 index 47a0b379..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/clearjav-api.yml +++ /dev/null @@ -1,146 +0,0 @@ ---- -id: clearjav-api -name: ClearJAV (API) -description: "ClearJAV is a Private Torrent Tracker for Japanese Adult Video (JAV) content from official JAV studios only." -language: en-US -type: private -encoding: UTF-8 -links: - - https://clearjav.com/ - -caps: - categorymappings: - - {id: 1, cat: XXX, desc: "JAV"} - - modes: - search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your ClearJAV account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must download or upload data within 30 days of joining, or your account may be pruned for lurking." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - name: "{{ .Keywords }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - # there are no categories on the site so using XXX as a default - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - text: 1 - title: - selector: name - details: - selector: details_link - download: - selector: download_link - files: - selector: num_file - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.1.7 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/coastalcrew.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/coastalcrew.yml deleted file mode 100644 index 2e325864..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/coastalcrew.yml +++ /dev/null @@ -1,230 +0,0 @@ ---- -id: coastalcrew -name: Coastal-Music-Crew -description: "Coastal-Music-Crew (C-M-C) is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://coastal-crew.bounceme.net/ - -caps: - categorymappings: - - {id: 32, cat: Movies/HD, desc: "Movie HD"} - - {id: 28, cat: Movies/SD, desc: "Movie SD"} - - {id: 140, cat: Movies/3D, desc: "Movie 3D"} - - {id: 138, cat: Movies/BluRay, desc: "Movie BluRay"} - - {id: 127, cat: Movies, desc: "Movie Pack"} - - {id: 124, cat: Movies, desc: "Movie Kids"} - - {id: 142, cat: Movies, desc: "Movie Anime"} - - {id: 122, cat: Movies, desc: "Movie Klassiker"} - - {id: 141, cat: Movies, desc: "Movie Klassiker 4K/UHD"} - - {id: 114, cat: Movies/DVD, desc: "Movie DVD-R"} - - {id: 26, cat: Movies/UHD, desc: "Movie 4K"} - - - {id: 133, cat: TV/Anime, desc: "Serien Anime"} - - {id: 57, cat: TV/HD, desc: "Serien HD"} - - {id: 59, cat: TV, desc: "Serien Pack"} - - {id: 60, cat: TV/SD, desc: "Serien SD"} - - {id: 56, cat: TV/SD, desc: "Serien DVD-R"} - - {id: 123, cat: TV, desc: "Serien Klassiker"} - - {id: 126, cat: TV, desc: "Serien Kids"} - - {id: 132, cat: TV/UHD, desc: "Serien UHD"} - - - {id: 92, cat: TV/Documentary, desc: "Doku Avi"} - - {id: 94, cat: TV/Documentary, desc: "Doku Audio"} - - {id: 135, cat: TV/Documentary, desc: "Doku UHD"} - - {id: 13, cat: TV/Documentary, desc: "Doku DVD-R"} - - {id: 14, cat: TV/Documentary, desc: "Doku HD"} - - {id: 15, cat: TV/Documentary, desc: "Doku SD"} - - {id: 136, cat: TV/Documentary, desc: "Doku Pack"} - - - {id: 134, cat: Other, desc: "Sonstiges Pack"} - - {id: 63, cat: Audio/Audiobook, desc: "Sonstiges A-Book"} - - {id: 96, cat: Books/EBook, desc: "Sonstiges E-Book"} - - {id: 69, cat: Other, desc: "Sonstiges Tutorial"} - - {id: 71, cat: Other/Misc, desc: "Sonstiges Other"} - - - {id: 12, cat: PC/0day, desc: "App Win"} - - {id: 9, cat: PC, desc: "App Linux"} - - {id: 139, cat: PC/Mobile-Android, desc: "App Android Apps"} - - {id: 11, cat: PC/Mobile-Other, desc: "App Other"} - - {id: 10, cat: PC/Mac, desc: "App Mac"} - - - {id: 16, cat: Console, desc: "Game Misc"} - - {id: 17, cat: PC/Games, desc: "Game PC"} - - {id: 18, cat: Console/PS3, desc: "Game PS2"} - - {id: 19, cat: Console/PS3, desc: "Game PS3"} - - {id: 20, cat: Console/PSP, desc: "Game PSP"} - - {id: 21, cat: Console/PS4, desc: "Game PSX"} - - {id: 22, cat: Console/Wii, desc: "Game Wii"} - - {id: 23, cat: Console/XBox, desc: "Game XBOX"} - - {id: 24, cat: Console/XBox 360, desc: "Game XBOX360"} - - - {id: 137, cat: Audio, desc: "Music OST"} - - {id: 54, cat: Audio, desc: "Music Single"} - - {id: 48, cat: Audio, desc: "Music Album"} - - {id: 49, cat: Audio, desc: "Music Charts"} - - {id: 52, cat: Audio, desc: "Music Pack"} - - {id: 53, cat: Audio, desc: "Music Sampler"} - - {id: 120, cat: Audio, desc: "Music Xmas"} - - {id: 121, cat: Audio/Lossless, desc: "Music Flac"} - - {id: 55, cat: Audio/Video, desc: "Music Video SD"} - - {id: 109, cat: Audio/Video, desc: "Music Video HD"} - - {id: 110, cat: Audio/Video, desc: "Music DVD-R"} - - {id: 128, cat: Audio, desc: "Music EP"} - - {id: 125, cat: Audio, desc: "Music Kids"} - - - {id: 61, cat: TV/Sport, desc: "Sport HD"} - - {id: 117, cat: TV/Sport, desc: "Sport DVD-R"} - - {id: 62, cat: TV/Sport, desc: "Sport SD"} - - - {id: 131, cat: TV/UHD, desc: "Internt'l Serie UHD"} - - {id: 103, cat: Movies/SD, desc: "Internt'l Movie SD"} - - {id: 98, cat: Movies/HD, desc: "Internt'l Movie HD"} - - {id: 102, cat: TV/SD, desc: "Internt'l Serie SD"} - - {id: 99, cat: TV/HD, desc: "Internt'l Serie HD"} - - {id: 115, cat: Movies/DVD, desc: "Internt'l DVD-R"} - - {id: 107, cat: Audio/Audiobook, desc: "Internt'l A-Book"} - - {id: 106, cat: Books/EBook, desc: "Internt'l E-Book"} - - {id: 105, cat: TV/Documentary, desc: "Internt'l Doku"} - - {id: 104, cat: Other, desc: "Internt'l Misc"} - - - {id: 74, cat: XXX/Other, desc: "XXX Other"} - - {id: 76, cat: XXX/SD, desc: "XXX SD"} - - {id: 73, cat: XXX/x264, desc: "XXX HD"} - - {id: 113, cat: XXX/DVD, desc: "XXX DVD-R"} - - {id: 75, cat: XXX/Pack, desc: "XXX Pack"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Anzahl der Torrents beim Durchsuchen Torrents per page: setting to 60 on your account profile. The default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "If you do not log in, your account will be automatically deleted after 28 days." - -login: - path: login.php - method: form - form: form[action="/login.php"] - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div#login_error - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both (options 1 and 2 are disabled) - blah: 0 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: div.selection_wrap - - fields: - category_p1: - selector: div.kat_cat_pic_name - category_p2: - selector: div.kat_cat_pic_name_b - categorydesc: - text: "{{ .Result.category_p1 }} {{ .Result.category_p2 }}" - title: - selector: a.selection_a - filters: - - name: re_replace - args: ["^\\[[\\w ]*\\]\\s?", ""] - details: - selector: a.selection_a - attribute: href - download: - selector: a[href^="download_ssl.php?torrent="] - attribute: href - poster: - selector: div[id^="details"] img - attribute: src - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - date_day: - # Heute 13:30:04 - # Gestern 09:10:10 - selector: div.selection_unter_ab:not(:contains(".")) - optional: true - filters: - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 30.02.2018 um 23:12:50 - selector: div.selection_unter_ab:contains(".") - optional: true - filters: - - name: replace - args: [" um", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# global MR is 1.0 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# 3xT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/comicat.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/comicat.yml deleted file mode 100644 index b09ac6a9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/comicat.yml +++ /dev/null @@ -1,110 +0,0 @@ ---- -id: comicat -name: comicat -description: "comicat is a CHINESE Semi-Private tracker for Anime / Hentai / Manga" -language: zh-CN -type: semi-private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.comicat.org/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "动画(Anime)"} - - {id: 2, cat: Books/Comics, desc: "漫画(Manga)"} - - {id: 3, cat: Audio, desc: "音乐(Music)"} - - {id: 4, cat: TV/Anime, desc: "周边(Peripheral)"} - - {id: 5, cat: Other, desc: "其它(Other)"} - - {id: 6, cat: TV/Anime, desc: "Raw"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -login: - path: addon.php?r=document/view&page=visitor-test - method: form - form: form#visitor-test-form - inputs: - visitor_test: human - -search: - paths: - # https://www.comicat.org/ (latest) - # https://www.comicat.org/search.php?keyword=720p+2020 (search) - # sort_id: filters by a single category, multi cats is not supported so we cannot use it. - - path: "{{ if .Keywords }}/search.php?keyword={{ .Keywords }}{{ else }}{{ end }}" - - path: "{{ if .Keywords }}/search.php?keyword={{ .Keywords }}&page=2{{ else }}2.html{{ end }}" - - rows: - selector: tr[class^="alt"]:has(a[href^="sort"]) - - fields: - category: - selector: a[href^="sort"] - attribute: href - filters: - - name: regexp - args: "-(\\d)-" - title: - selector: a[href^="show"] - details: - selector: a[href^="show"] - attribute: href - infohash: - selector: a[href^="show"] - attribute: href - filters: - - name: regexp - args: "-(\\w+).html" - size: - selector: td:nth-child(4) - date_year: - selector: td:nth-child(1):contains("/") - optional: true - filters: - - name: append - args: " +08:00" # CST (China) - - name: dateparse - args: "yyyy/MM/dd zzz" - date_today: - selector: td:nth-child(1):contains("今天") - optional: true - filters: - - name: replace - args: ["今天", ""] - - name: append - args: " +08:00" # CST (China) - - name: timeparse - args: "HH:mm zzz" - date_yday: - selector: td:nth-child(1):contains("昨天") - optional: true - filters: - - name: re_replace - args: [".+", "yesterday"] - - name: fuzzytime - date_ago: - selector: td:nth-child(1):contains("前天") - optional: true - filters: - - name: re_replace - args: [".+", "2 days"] - - name: timeago - date: - text: "{{ if or .Result.date_year .Result.date_today .Result.date_yday .Result.date_ago }}{{ or .Result.date_year .Result.date_today .Result.date_yday .Result.date_ago }}{{ else }}now{{ end }}" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/concen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/concen.yml deleted file mode 100644 index 8a72d738..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/concen.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -id: concen -name: ConCen -description: "ConCen (Conspiracy Central) is a Private conspiracy related torrent index" -language: en-US -type: private -encoding: UTF-8 -links: - - https://concen.org/ - -caps: - categories: - 1: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: created - options: - created: created - seeds: seeders - size: size - title: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -search: - paths: - # https://concen.org/torrents?title_op=allwords&title=cbc+national&title_1_op=not&title_1=&seeds=1 - - path: torrents - inputs: - title_op: allwords - title: "{{ .Keywords }}" - title_1_op: not - title_1: "" - seeds: All - order: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: table > tbody > tr:has(td.views-field-title) - - fields: - title: - selector: td.views-field-title a - category: - text: 1 - details: - selector: td.views-field-title a - attribute: href - download: - selector: td.views-field-field-torrent a - attribute: href - magnet: - selector: td.views-field-name a - attribute: href - date: - # 3 hours 27 min - selector: td.views-field-created - size: - selector: td.views-field-size - seeders: - selector: td.views-field-seeds - leechers: - selector: td.views-field-peers - grabs: - selector: td.views-field-completed - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Drupal 7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/concertos.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/concertos.yml deleted file mode 100644 index cad9cf27..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/concertos.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -id: concertos -name: Concertos (API) -description: "Concertos is a Private Torrent Tracker for Live Concerts with Strict Quality Control" -language: en-US -type: private -encoding: UTF-8 -links: - - https://concertos.live/ - -caps: - categorymappings: - - {id: 1, cat: TV, desc: "Live Concert"} - - {id: 2, cat: Audio/Video, desc: "Music Video"} - - {id: 4, cat: TV, desc: "Music Documentary"} - - {id: 5, cat: Audio/Lossless, desc: "Pure Audio"} - - {id: 7, cat: TV, desc: "Opera"} - - {id: 8, cat: TV, desc: "Musical"} - - {id: 9, cat: Audio, desc: "Podcast"} - - {id: 10, cat: TV, desc: "Educational"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Concertos account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your membership requires that you stay active at Blutopia. To not get disabled, you are required to login to the site at least once every 30 days or be actively seeding at least 1 torrent." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 1.0 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.1.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crabpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crabpt.yml deleted file mode 100644 index a6b611c5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crabpt.yml +++ /dev/null @@ -1,218 +0,0 @@ ---- -id: crabpt -name: CrabPT (蟹黄堡) -description: "CrabPT (蟹黄堡) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://crabpt.vip/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "电影 / Movies", default: true} - - {id: 402, cat: TV, desc: "电视剧 / TVSeries", default: true} - - {id: 413, cat: TV/Other, desc: "短剧 / Playlet", default: true} - - {id: 408, cat: Audio, desc: "音乐 / Music", default: true} - - {id: 405, cat: TV/Anime, desc: "动漫 / Anime", default: true} - - {id: 406, cat: Audio/Video, desc: "MV", default: true} - - {id: 403, cat: TV, desc: "综艺 / TV Shows", default: true} - - {id: 407, cat: TV/Sport, desc: "体育竞技 / Sports", default: true} - - {id: 404, cat: TV/Documentary, desc: "纪录片 / Documentary", default: true} - - {id: 409, cat: Other, desc: "其他 / Other", default: true} - - {id: 415, cat: Books/Comics, desc: "漫画 / Cartoon", default: true} - - {id: 414, cat: Console, desc: "游戏 / Game", default: true} - - {id: 412, cat: Other, desc: "学习 / Study", default: true} - - {id: 411, cat: Audio/Audiobook, desc: "有声书 / Audiobook", default: true} - - {id: 410, cat: Books/EBook, desc: "电子书 / Ebook", default: true} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the CrabPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 413, 408, 405, 406, 403, 407, 404, 409] - - path: special.php - categories: [415, 414, 412, 411, 410] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crackingpatching.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crackingpatching.yml deleted file mode 100644 index ffaf35d7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crackingpatching.yml +++ /dev/null @@ -1,103 +0,0 @@ ---- -id: crackingpatching -name: CrackingPatching -description: "CrackingPatching is a Public tracker for Software and Apps" -language: en-US -type: public -encoding: UTF-8 -links: - - https://crackingpatching.com/ - -caps: - categorymappings: - - {id: "adobe-software", cat: PC, desc: "Adobe Software"} - - {id: "adobe-tools", cat: PC, desc: "Adobe Tools"} - - {id: "android", cat: PC/Mobile-Android, desc: "Android"} - - {id: "animations-3d-graphics", cat: PC, desc: "Animations/ 3D Graphics"} - - {id: "antivirus", cat: PC, desc: "Antivirus"} - - {id: "cd-dvd-burners", cat: PC, desc: "CD/ DVD Burners"} - - {id: "compression-tools", cat: PC, desc: "Compression Tools"} - - {id: "converters", cat: PC, desc: "Converters"} - - {id: "crack-serials", cat: PC, desc: "Crack & Serials"} - - {id: "downloader", cat: PC, desc: "Downloader"} - - {id: "drivers-update", cat: PC, desc: "Drivers Update"} - - {id: "games", cat: PC/Games, desc: "Games"} - - {id: "home", cat: PC, desc: "Home"} - - {id: "idm", cat: PC, desc: "IDM"} - - {id: "idm-crack-patch", cat: PC, desc: "IDM Crack/ Patch"} - - {id: "ios-mac-os-x-2", cat: PC/Mac, desc: "iOS/ MAC OS X"} - - {id: "keygen-loader", cat: PC, desc: "Keygen / Loader"} - - {id: "keygen-serial", cat: PC, desc: "KeyGen / Serial"} - - {id: "microsoft-office", cat: PC, desc: "Microsoft Office"} - - {id: "multimedia", cat: PC, desc: "Multimedia"} - - {id: "other", cat: Other, desc: "Other"} - - {id: "pdf-tools", cat: PC, desc: "PDF Tools"} - - {id: "photo-editing-tools", cat: PC, desc: "Photo Editing Tools"} - - {id: "recovery-software", cat: PC, desc: "Recovery Software"} - - {id: "request-crack-patch", cat: PC, desc: "Request Crack/ Patch"} - - {id: "screen-recorders", cat: PC, desc: "Screen Recorders"} - - {id: "security", cat: PC, desc: "Security"} - - {id: "system-optimizers", cat: PC, desc: "System Optimizers"} - - {id: "uncategorized", cat: Other, desc: "Uncategorized"} - - {id: "vpn", cat: PC, desc: "VPN"} - - {id: "windows", cat: PC, desc: "Windows"} - - {id: "windows-app", cat: PC, desc: "Windows App"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - - rows: - selector: article.post-entry - filters: - - name: andmatch - - fields: - category_optional: - selector: article.post-entry - attribute: class - optional: true - filters: - - name: regexp - args: "category-(.+?) " - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}other{{ end }}" - title: - selector: h2 > a - details: - selector: h2 > a - attribute: href - download: - selector: h2 > a - attribute: href - poster: - selector: img - attribute: src - date: - text: now - size: - text: "512 MB" - description: - selector: p - remove: a - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# WordPress 5.9.1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crazyspirits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crazyspirits.yml deleted file mode 100644 index 0f24a081..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crazyspirits.yml +++ /dev/null @@ -1,294 +0,0 @@ ---- -id: crazyspirits -name: CrazySpirits -description: "Crazy Spirits is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://www.crazyspirits.ca/ -legacylinks: - - https://www.crazyspirits.com/ - -caps: - categorymappings: - # Films - - {id: 49, cat: Movies/3D, desc: "Films 3D"} - - {id: 1, cat: XXX, desc: "Films 3X"} - - {id: 48, cat: XXX/x264, desc: "Films 3X HD1080p"} - - {id: 47, cat: XXX/x264, desc: "Films 3X HD720p"} - - {id: 140, cat: XXX, desc: "Films 3X Video"} - - {id: 50, cat: Movies/UHD, desc: "Films 4K"} - - {id: 51, cat: TV/UHD, desc: "Films 4K UHDTV"} - - {id: 57, cat: Movies/BluRay, desc: "Films BD-Rip"} - - {id: 56, cat: Movies/BluRay, desc: "Films Blu-Ray"} - - {id: 58, cat: Movies/BluRay, desc: "Films BR-Rip"} - - {id: 66, cat: Movies/SD, desc: "Films Cam-TS"} - - {id: 96, cat: TV/Documentary, desc: "Docus TV"} - - {id: 59, cat: Movies/DVD, desc: "Films DVDR"} - - {id: 60, cat: Movies/DVD, desc: "Films DVDRip"} - - {id: 98, cat: Movies/Other, desc: "Films Animé"} - - {id: 126, cat: TV/HD, desc: "Films HDTV"} - - {id: 124, cat: TV/Documentary, desc: "Formation Vidéo"} - - {id: 65, cat: Movies, desc: "Films FSCR"} - - {id: 53, cat: Movies/HD, desc: "Films HD1080p"} - - {id: 52, cat: Movies/HD, desc: "Films HD 720p"} - - {id: 137, cat: Movies/UHD, desc: "Films HDR 2160p"} - - {id: 61, cat: Movies/HD, desc: "Films HDRip"} - - {id: 63, cat: Movies/HD, desc: "Films MHD"} - - {id: 67, cat: Movies, desc: "Films Pack Films"} - - {id: 129, cat: Movies, desc: "Films QC~Film"} - - {id: 64, cat: Movies, desc: "Films R5"} - - {id: 99, cat: Movies, desc: "Films Remux"} - - {id: 69, cat: Movies, desc: "Films VO"} - - {id: 68, cat: Movies, desc: "Films Vost-FR"} - - {id: 122, cat: Movies/WEB-DL, desc: "Films Web-DL 1080P"} - - {id: 138, cat: Movies/WEB-DL, desc: "Films WEB-DL 2160p"} - - {id: 123, cat: Movies/WEB-DL, desc: "Films WEB-DL 720p"} - - {id: 143, cat: Movies/WEB-DL, desc: "Films WEB-DL-SD"} - - {id: 139, cat: Movies/WEB-DL, desc: "Films WEB-RIP 2160p"} - - {id: 73, cat: Movies/WEB-DL, desc: "Films Webrip"} - - {id: 70, cat: Movies/WEB-DL, desc: "Films WebRip 1080p"} - - {id: 71, cat: Movies/WEB-DL, desc: "Films WebRip 720p"} - - {id: 72, cat: Movies/WEB-DL, desc: "Films WEBRip-DL"} - # Jeux - - {id: 114, cat: PC/Games, desc: "Jeux PC"} - - {id: 115, cat: Console/PS3, desc: "Jeux PS3"} - - {id: 116, cat: Console/PSP, desc: "Jeux PSP- PSX"} - - {id: 117, cat: Console, desc: "Jeux Roms"} - - {id: 118, cat: Console, desc: "Jeux Switch"} - - {id: 119, cat: Console/Wii, desc: "Jeux Wii"} - - {id: 120, cat: Console/WiiU, desc: "Jeux WiiU"} - - {id: 121, cat: Console/XBox, desc: "Jeux Xbox"} - # Livre - - {id: 100, cat: Audio/Audiobook, desc: "Livre Audio"} - - {id: 101, cat: Books/EBook, desc: "Livre Epub"} - - {id: 102, cat: Books, desc: "Livre Mangas"} - - {id: 135, cat: Books, desc: "Livre Québéçois"} - - {id: 103, cat: Books, desc: "Livres BD"} - - {id: 104, cat: Books/Comics, desc: "Livres Comics"} - - {id: 105, cat: Books/Mags, desc: "Livres Press"} - - {id: 106, cat: Books, desc: "Livres Romans"} - # Logiciel - - {id: 113, cat: PC/Mobile-iOS, desc: "Logiciel Apple"} - - {id: 111, cat: PC, desc: "Logiciel Linux"} - - {id: 112, cat: PC/Mobile-Other, desc: "Logiciel Smart Phone"} - - {id: 110, cat: PC/0day, desc: "Logiciel Windows"} - # Musiques - - {id: 108, cat: Audio/Lossless, desc: "Musiques FLAC"} - - {id: 107, cat: Audio/MP3, desc: "Musiques MP3"} - - {id: 141, cat: Audio, desc: "Musiques Pack Album"} - - {id: 125, cat: Audio/Video, desc: "Musiques Videos Pack"} - - {id: 132, cat: Audio, desc: "Musiques QC~Musique"} - - {id: 109, cat: Audio, desc: "Musiques WMA"} - # Séries - - {id: 130, cat: TV, desc: "Séries QC~Serie"} - - {id: 97, cat: TV/Anime, desc: "Séries Animée"} - - {id: 84, cat: TV/HD, desc: "Séries Épisodes HD"} - - {id: 85, cat: TV/SD, desc: "Séries Épisodes SD"} - - {id: 77, cat: TV/HD, desc: "Séries HDTV"} - - {id: 88, cat: TV, desc: "Séries Pack HD"} - - {id: 128, cat: TV, desc: "Séries Pack SD"} - # Spéctacle - - {id: 131, cat: TV/Other, desc: "Spectacle QC~Spectacle"} - - {id: 90, cat: TV/Other, desc: "Spectacle 1080p"} - - {id: 91, cat: TV/Other, desc: "Spectacle 720p"} - - {id: 142, cat: TV/Other, desc: "Spectacle QC"} - - {id: 136, cat: TV/Other, desc: "Spectacle SD"} - # Télévision - - {id: 133, cat: TV, desc: "TV QC~Tv"} - - {id: 94, cat: TV, desc: "TV Animation"} - - {id: 93, cat: TV/Documentary, desc: "TV Documentaires"} - - {id: 92, cat: TV/Other, desc: "TV Spectacles"} - - {id: 95, cat: TV/Sport, desc: "TV Sports"} - - {id: 74, cat: TV/HD, desc: "TV TVHD"} - - {id: 75, cat: TV, desc: "TV TVRip"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: torrents-search.php - selector: a[href="account-logout.php"] - -download: - before: - path: thanks.php - inputs: - id: "{{ .DownloadUri.Query.id }}" - to: give - torrent: "{{ .DownloadUri.Query.id }}" - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - # https://www.crazyspirits.com/torrents-search.php?search=&cat=0&incldead=1&freeleech=0&recommended=0&lang=0 - - path: torrents-search.php - inputs: -# using multi cat search causes http 500 internal server error -# $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - cat: 0 - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 nofree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 all, 1 not recommended, 2 only recommended - recommended: 0 - # 0 all, 1 french, 2 english, etc - lang: 0 - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # imdb search not supported and imdb links not in results. - - rows: - selector: table.border_table > tbody > tr.t-row - - fields: - title_phase0: - # title can be abbreviated - selector: a[href^="torrents-details.php?id="] b - title_phase1: - # if available, the youtube link has a full title - selector: a[rel="prettyPhoto"] img - attribute: alt - optional: true - filters: - - name: replace - args: ["-NoTag", ""] - title_phase2: - text: "{{ if .Result.title_phase1 }}{{ .Result.title_phase1 }}{{ else }}{{ .Result.title_phase0 }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase3: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase3 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase3 }}{{ end }}" - category: - selector: a[href^="torrents.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="torrents-details.php?id="] - attribute: href - poster: - selector: a[onmouseover][href^="torrents-details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.*?)><" - grabs: - selector: a[onmouseover][href^="torrents-details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "Completé : (\\d+)<" - seeders: - selector: a[onmouseover][href^="torrents-details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "=greene>(\\d+)<" - leechers: - selector: a[onmouseover][href^="torrents-details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "=red>(\\d+)<" - size: - selector: a[onmouseover][href^="torrents-details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "Taille : (.*?)(.*?)08:10 AM - # Juče
06:44 PM - selector: td:nth-of-type(6):contains("Danas"), td:nth-of-type(6):contains("Juče") - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: ["Danas", "Today"] - - name: replace - args: ["Juče", "Yesterday"] - date_year: - # Feb 14 2019
10:20 AM - selector: td:nth-of-type(6):not(:contains("Juče")):not(:contains("Danas")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM d yyyy hh:mm tt" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-of-type(7) - grabs: - selector: td:nth-of-type(8) a - filters: - - name: regexp - args: ([\d]+) - seeders: - selector: td:nth-of-type(9) - leechers: - selector: td:nth-of-type(10) - downloadvolumefactor: - case: - "img[src$=\"/pic/freedownload.gif\"]": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 7 day (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# U-232 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crt2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crt2fa.yml deleted file mode 100644 index 811f9531..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/crt2fa.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: crt2fa -name: CRT2FA -description: "Cathode-Ray.Tube (CRT) is a Private Torrent Tracker for CLASSIC MOVIES / TV. This indexer uses cookie login for 2FA." -language: en-GB -type: private -encoding: UTF-8 -links: - - https://www.cathode-ray.tube/ - -caps: - categorymappings: - - {id: 13, cat: PC/Games, desc: "Games"} - - {id: 4, cat: Other, desc: "Misc"} - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Other, desc: "WOC"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, genre] - movie-search: [q, imdbid, genre] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "New members must generate some torrent activity (upload, download, or cross-seed) in their account within the first 30 days of joining, otherwise their account will be automatically disabled." - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}" - title: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ end }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - taglist: "{{ .Query.Genre }}" - - keywordsfilters: - - name: re_replace - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1"] - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - category: - selector: a[href^="/torrents.php?filter_cat"] - attribute: href - filters: - - name: regexp - args: \[(\d+)\] - title: - selector: a[href^="/torrents.php?id="] - filters: - - name: re_replace # Seasons X-Y --> S0X-S0Y - args: ["(?i)\\bSeasons?\\s*(\\d)\\s*-\\s*(\\d)\\b", "S0$1-S0$2"] - - name: re_replace # Seasons X-YY --> S0X-SYY - args: ["(?i)\\bSeasons?\\s*(\\d)\\s*-\\s*(\\d+)\\b", "S0$1-S$2"] - - name: re_replace # Seasons XX-YY --> SXX-SYY - args: ["(?i)\\bSeasons?\\s*(\\d+)\\s*-\\s*(\\d+)\\b", "S$1-S$2"] - - name: re_replace # Season X --> S0X - args: ["(?i)\\bSeasons?\\s*(\\d)\\b", "S0$1"] - - name: re_replace # Season XX --> SXX - args: ["(?i)\\bSeasons?\\s*(\\d+)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\bComplete\\s+Series\\b", ""] - details: - selector: a[href^="/torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - genre: - selector: div.tags - filters: - - name: validate - args: "Action, Adventure, Animation, Comedy, Crime, Documentary, Drama, Family, Fantasy, History, Horror, Kids, Music, Mystery, News, Reality, Romance, SciFi, Soap, Talk, Thriller, War, Western" - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/styles/modern/crt.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 1.0 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cspt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cspt.yml deleted file mode 100644 index f7dedae2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cspt.yml +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: cspt -name: Caishen (财神) -description: "Caishen (财神) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://cspt.top/ - -caps: - categorymappings: - - {id: 408, cat: Audio, desc: "HQ音乐 / HQ Audio"} - - {id: 409, cat: Other, desc: "其他 / Misc"} - - {id: 407, cat: TV/Sport, desc: "体育 / Sports"} - - {id: 406, cat: Audio/Video, desc: "MV / Music Videos"} - - {id: 404, cat: TV/Documentary, desc: "纪录片 / Documentary"} - - {id: 403, cat: TV, desc: "综艺 / TV Shows"} - - {id: 402, cat: TV, desc: "电视剧 / TVSeries"} - - {id: 401, cat: Movies, desc: "电影 / Movies"} - - {id: 405, cat: TV/Anime, desc: "动漫 / Anime"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users with no traffic (i.e. upload/download data is 0) will be deleted if they have not logged in for more than 100 consecutive days.
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: div.torrents > div > div.torrent-table-sub-info - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: div.torrent-info-text-added > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: div.torrent-info-text-added:not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: div.torrent-info-text-size - seeders: - selector: div.torrent-info-text-seeders - leechers: - selector: div.torrent-info-text-leechers - grabs: - selector: div.torrent-info-text-finished - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description_verified: - case: - span[title="通过"]: "Verified:" - span[title="未审"]: "Unverified:" - span[title="拒绝"]: "Banned:" - description: - selector: div.text-start - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cyanbug.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cyanbug.yml deleted file mode 100644 index 070aaa2b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/cyanbug.yml +++ /dev/null @@ -1,214 +0,0 @@ ---- -id: cyanbug -name: cyanbug (大青虫) -description: "cyanbug (大青虫) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://cyanbug.net/ - -caps: - categorymappings: - - {id: 409, cat: Other, desc: "Others/其他"} - - {id: 408, cat: Audio, desc: "Music/音轨"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the cyanbug Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname):has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 1 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 1 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 1 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/damagnet.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/damagnet.yml deleted file mode 100644 index a0b3f4c4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/damagnet.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -id: damagnet -name: DaMagNet -description: "DaMagNet is a Public DHT Crawler" -language: en-US -type: public -encoding: UTF-8 -links: - - https://damag.net/ - -caps: - categorymappings: - - {id: Other, cat: Other, desc: Other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - -download: - infohash: - hash: - selector: a[href^="magnet:?xt"] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: input[name="q"] - attribute: value - filters: - - name: validfilename - -search: - paths: - # https://damag.net/?q=2025&wanted=100 - - path: / - inputs: - q: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - # 20, 50, 100 - wanted: 100 - - headers: - Referer: ["{{ .Config.sitelink }}"] - - rows: - selector: table.table-striped > tbody > tr:has(a) - filters: - - name: andmatch - - fields: - category: - text: Other - title: - selector: a - details: - selector: a - attribute: href - download: - selector: a - attribute: href - date: - text: now - files: - selector: span.label - size: - selector: span[id^="size"] - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/darkpeers.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/darkpeers.yml deleted file mode 100644 index a9f33cba..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/darkpeers.yml +++ /dev/null @@ -1,200 +0,0 @@ ---- -id: darkpeers-api -name: Darkpeers (API) -description: "Darkpeers is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://darkpeers.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 4, cat: Console, desc: "Games"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 5, cat: PC, desc: "Software"} - - {id: 6, cat: XXX, desc: "XXX"} - - {id: 7, cat: Other, desc: "Assorted"} - - {id: 8, cat: Books, desc: "Books"} - - {id: 9, cat: TV/Anime, desc: "Anime"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Darkpeers account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Users must log in or be seeding for their account to be considered active. If the account is not active for a consecutive 90 days, the account will be deactivated. After a consecutive 120 days, the account will be pruned." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.8 but torrents must be seeded for 6 days or 1:1 - minimumratio: - text: 1.0 - minimumseedtime: - # 6 days (as seconds = 6 x 24 x 60 x 60) - text: 518400 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dasunerwartete-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dasunerwartete-api.yml deleted file mode 100644 index 5709b565..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dasunerwartete-api.yml +++ /dev/null @@ -1,144 +0,0 @@ ---- -id: dasunerwartete-api -name: Das Unerwartete (API) -description: "Das Unerwartete (D-U) is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL. This is the API version." -language: de-DE -type: private -encoding: UTF-8 -links: - - https://dasunerwartete.biz/ - -caps: - categorymappings: - - {id: 145, cat: Movies, desc: "Filme > DRG"} - - {id: 28, cat: Movies/SD, desc: "Filme > SD"} - - {id: 32, cat: Movies/HD, desc: "Filme > HD"} - - {id: 114, cat: Movies/UHD, desc: "Filme > UHD"} - - {id: 140, cat: Movies/BluRay, desc: "Filme > BluRay"} - - {id: 143, cat: TV/Anime, desc: "Filme > Anime"} - - {id: 138, cat: Movies, desc: "Filme > Packs"} - - {id: 148, cat: Movies, desc: "Filme > Packs UHD"} - - {id: 60, cat: TV/SD, desc: "Serien > SD"} - - {id: 57, cat: TV/HD, desc: "Serien > HD"} - - {id: 139, cat: TV/UHD, desc: "Serien > UHD"} - - {id: 144, cat: TV/Anime, desc: "Serien > Anime"} - - {id: 59, cat: TV, desc: "Serien > Packs"} - - {id: 147, cat: TV, desc: "Serien > Packs UHD"} - - {id: 110, cat: Audio/MP3, desc: "Audio > MP3"} - - {id: 48, cat: Audio/Lossless, desc: "Audio > Flac"} - - {id: 52, cat: Audio, desc: "Audio > Pack"} - - {id: 63, cat: Audio/Audiobook, desc: "Audio > Hörbuch"} - - {id: 120, cat: Audio/Video, desc: "Audio > Videos"} - - {id: 12, cat: PC/0day, desc: "Apps > Windows"} - - {id: 125, cat: PC/Mac, desc: "Apps > Mac-OSX"} - - {id: 11, cat: PC/Mobile-Other, desc: "Apps > Mobil"} - - {id: 141, cat: PC, desc: "Apps > Other"} - - {id: 15, cat: TV/Documentary, desc: "Doku > SD"} - - {id: 14, cat: TV/Documentary, desc: "Doku > HD"} - - {id: 123, cat: TV/Documentary, desc: "Doku > Reihe"} - - {id: 126, cat: PC/Mobile-Other, desc: "Spiel > Mobil"} - - {id: 17, cat: PC/Games, desc: "Spiel > PC"} - - {id: 24, cat: Console, desc: "Spiel > Konsole"} - - {id: 121, cat: Console, desc: "Spiel > Wimmelbild"} - - {id: 61, cat: TV/Sport, desc: "Sport > HD"} - - {id: 62, cat: TV/Sport, desc: "Sport > SD"} - - {id: 96, cat: Books/Mags, desc: "ePaper > Zeitungen"} - - {id: 136, cat: Books/EBook, desc: "ePaper > E-Book"} - - {id: 137, cat: Books/Comics, desc: "ePaper > Comics"} - - {id: 76, cat: XXX/SD, desc: "XXX > SD"} - - {id: 73, cat: XXX/x264, desc: "XXX > HD"} - - {id: 75, cat: XXX/Pack, desc: "XXX > Pack"} - - {id: 142, cat: XXX/ImageSet, desc: "XXX > Pic´s"} - - {id: 129, cat: Movies/SD, desc: "Internal > Film SD"} - - {id: 128, cat: Movies/HD, desc: "Internal > Film HD"} - - {id: 131, cat: TV/SD, desc: "Internal > Serien SD"} - - {id: 130, cat: TV/HD, desc: "Internal > Serien HD"} - - {id: 132, cat: Other, desc: "Internal > Sonstiges"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find your API Key by accessing your Das Unerwartete profile page and look for the API-Key: tag." - - name: onlyupload - type: checkbox - label: Search OnlyUpload - default: false - - name: info_free - type: info - label: About OnlyUpload at D-U - default: "OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech)." - -search: - paths: - - path: api.php - method: get - response: - type: xml - inputs: - apikey: "{{ .Config.apikey }}" - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - q: "{{ .Query.Keywords }}" - imdbid: "{{ .Query.IMDBID }}" - tmdbid: "{{ .Query.TMDBID }}" - free: "{{ if .Config.onlyupload }}yes{{ else }}{{ end }}" - page: 1 - - rows: - selector: item - - fields: - category: - selector: category - title: - selector: title - _id: - selector: guid - details: - text: "details.php?id={{ .Result._id }}" - download: - selector: link - filters: - - name: replace - args: ["&", "&"] - size: - selector: size - date: - # Thu, 30 Oct 2025 00:19:02 +0100 - selector: pubDate - filters: - - name: replace - args: ["+0100", "+01:00"] - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - seeders: - selector: seeders - leechers: - selector: leechers - imdbid: - selector: imdb - tmdbid: - selector: tmdb - _onlyup: - selector: free_button:contains(Only Upload) - optional: true - downloadvolumefactor: - text: "{{ if .Result._onlyup }}0{{ else }}1{{ end }}" - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1.5 days (as seconds = 1.5 x 24 x 60 x 60) - text: 129600 -# API XML diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dasunerwartete.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dasunerwartete.yml deleted file mode 100644 index 04e2780d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dasunerwartete.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: dasunerwartete -name: Das Unerwartete -description: "Das Unerwartete (D-U) is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://dasunerwartete.biz/ - -caps: - categorymappings: - - {id: 145, cat: Movies, desc: "Filme > DRG"} - - {id: 28, cat: Movies/SD, desc: "Filme > SD"} - - {id: 32, cat: Movies/HD, desc: "Filme > HD"} - - {id: 114, cat: Movies/UHD, desc: "Filme > UHD"} - - {id: 140, cat: Movies/BluRay, desc: "Filme > BluRay"} - - {id: 143, cat: TV/Anime, desc: "Filme > Anime"} - - {id: 138, cat: Movies, desc: "Filme > Packs"} - - {id: 148, cat: Movies, desc: "Filme > Packs UHD"} - - {id: 60, cat: TV/SD, desc: "Serien > SD"} - - {id: 57, cat: TV/HD, desc: "Serien > HD"} - - {id: 139, cat: TV/UHD, desc: "Serien > UHD"} - - {id: 144, cat: TV/Anime, desc: "Serien > Anime"} - - {id: 59, cat: TV, desc: "Serien > Packs"} - - {id: 147, cat: TV, desc: "Serien > Packs UHD"} - - {id: 110, cat: Audio/MP3, desc: "Audio > MP3"} - - {id: 48, cat: Audio/Lossless, desc: "Audio > Flac"} - - {id: 52, cat: Audio, desc: "Audio > Pack"} - - {id: 63, cat: Audio/Audiobook, desc: "Audio > Hörbuch"} - - {id: 120, cat: Audio/Video, desc: "Audio > Videos"} - - {id: 12, cat: PC/0day, desc: "Apps > Windows"} - - {id: 125, cat: PC/Mac, desc: "Apps > Mac-OSX"} - - {id: 11, cat: PC/Mobile-Other, desc: "Apps > Mobil"} - - {id: 141, cat: PC, desc: "Apps > Other"} - - {id: 15, cat: TV/Documentary, desc: "Doku > SD"} - - {id: 14, cat: TV/Documentary, desc: "Doku > HD"} - - {id: 123, cat: TV/Documentary, desc: "Doku > Reihe"} - - {id: 126, cat: PC/Mobile-Other, desc: "Spiel > Mobil"} - - {id: 17, cat: PC/Games, desc: "Spiel > PC"} - - {id: 24, cat: Console, desc: "Spiel > Konsole"} - - {id: 121, cat: Console, desc: "Spiel > Wimmelbild"} - - {id: 61, cat: TV/Sport, desc: "Sport > HD"} - - {id: 62, cat: TV/Sport, desc: "Sport > SD"} - - {id: 96, cat: Books/Mags, desc: "ePaper > Zeitungen"} - - {id: 136, cat: Books/EBook, desc: "ePaper > E-Book"} - - {id: 137, cat: Books/Comics, desc: "ePaper > Comics"} - - {id: 76, cat: XXX/SD, desc: "XXX > SD"} - - {id: 73, cat: XXX/x264, desc: "XXX > HD"} - - {id: 75, cat: XXX/Pack, desc: "XXX > Pack"} - - {id: 142, cat: XXX/ImageSet, desc: "XXX > Pic´s"} - - {id: 129, cat: Movies/SD, desc: "Internal > Film SD"} - - {id: 128, cat: Movies/HD, desc: "Internal > Film HD"} - - {id: 131, cat: TV/SD, desc: "Internal > Serien SD"} - - {id: 130, cat: TV/HD, desc: "Internal > Serien HD"} - - {id: 132, cat: Other, desc: "Internal > Sonstiges"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: info_password - type: info - label: About Password Expiry - default: "D-U has an 20 week Password Expiry regime. So if the Indexer has stopped working then the first thing you should do is check if your password has expired by login in at Das Unerwartete, and if necessary use the Passwort anfordern (Request password) button to renew it." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: onlyupload - type: checkbox - label: Search OnlyUpload - default: false - - name: info_free - type: info - label: About OnlyUpload at D-U - default: "OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech)." - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Anzahl der Torrents beim Durchsuchen: setting to 60 on your Control Panel. The default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts will be deactivated after 7 weeks and deleted after another 3 weeks." - -login: - path: login.php - method: form - form: form - inputs: - login_identifier: "{{ .Config.username }}" - password: "{{ .Config.password }}" - loggiin: Login - error: - - selector: div#login_error - - selector: div.error-message - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://dasunerwartete.biz/torrents_search.php?ajax=true&search=1080p&page=1&imdb=1034320&free=yes&c32=1 - - path: torrents_search.php - response: - type: json - - inputs: - ajax: true - search: "{{ .Keywords }}" - page: 1 - imdb: "{{ .Query.IMDBIDShort }}" - # all=active+inactive, yes=active, no=inactive (the default is all, which ajax simply drops the parm) - # visible: all - # all=all+onlyup, yes=onlyup, no=without onlyup (the default is all, which ajax simply drops the parm) - free: "{{ if .Config.onlyupload }}yes{{ else }}{{ end }}" - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - - rows: - selector: data - - fields: - categorydesc: - selector: category - filters: - - name: htmldecode - title: - selector: name - _id: - selector: id - details: - text: "details.php?id={{ .Result._id }}" - download: - selector: download_link - poster: - selector: cover - size: - selector: size - date: - # 25.12.2024 16:02 - selector: added - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm zzz" - seeders: - selector: seeders - leechers: - selector: leechers - _onlyup: - selector: free_button:contains(Only Upload) - optional: true - downloadvolumefactor: - text: "{{ if .Result._onlyup }}0{{ else }}1{{ end }}" - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1.5 days (as seconds = 1.5 x 24 x 60 x 60) - text: 129600 -# THX JSON (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/datascene-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/datascene-api.yml deleted file mode 100644 index 8d210210..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/datascene-api.yml +++ /dev/null @@ -1,228 +0,0 @@ ---- -id: datascene-api -name: DataScene (API) -description: "DataScene (DS) is a ROMANIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://datascene.xyz/ -legacylinks: - - http://datascene.net/ - - https://datascene.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies/HD, desc: "Movie HD"} - - {id: 23, cat: Movies/HD, desc: "Movies HD Ro"} - - {id: 11, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 12, cat: Movies/UHD, desc: "Movies 4K Ro"} - - {id: 14, cat: Movies/BluRay, desc: "Movies Bluray"} - - {id: 15, cat: Movies/BluRay, desc: "Movies Bluray Ro"} - - {id: 27, cat: Movies/DVD, desc: "DVD"} - - {id: 28, cat: Movies/DVD, desc: "DVD Ro"} - - {id: 19, cat: Movies/SD, desc: "SD"} - - {id: 20, cat: Movies/SD, desc: "SD Ro"} - - {id: 2, cat: TV/HD, desc: "TV HD"} - - {id: 18, cat: TV/HD, desc: "TV HD Ro"} - - {id: 26, cat: TV/SD, desc: "TV SD"} - - {id: 10, cat: TV/SD, desc: "TV SD Ro"} - - {id: 31, cat: TV/SD, desc: "DVD"} - - {id: 32, cat: TV/SD, desc: "DVD Ro"} - - {id: 29, cat: TV/UHD, desc: "TV 4K"} - - {id: 30, cat: TV/UHD, desc: "TV 4K Ro"} - - {id: 22, cat: TV, desc: "TV Pack"} - - {id: 33, cat: TV, desc: "TV Pack Ro"} - - {id: 4, cat: PC/Games, desc: "Game"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 21, cat: Audio/Video, desc: "Music Video"} - - {id: 7, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 24, cat: PC, desc: "Linux"} - - {id: 8, cat: Books/EBook, desc: "E-Book"} - - {id: 25, cat: TV/Sport, desc: "Sport"} - - {id: 5, cat: PC/0day, desc: "Application"} - - {id: 13, cat: TV/Anime, desc: "Anime"} - - {id: 6, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your DataScene account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "
  • Accounts with zero activity (0 bytes download and 0 bytes upload) are automatically deleted after 30 days.
  • Inactive account under the rank of VIP that are unparked are automatically deleted after 40 days.
" - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(All Sex)", "All_Sex"] - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 8.3.6 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/deildu.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/deildu.yml deleted file mode 100644 index dae22f15..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/deildu.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -id: deildu -name: Deildu -description: "Deildu is an ICELANDIC Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: is-IS -type: semi-private -encoding: iso-8859-1 -links: - - https://deildu.net/ - -caps: - categorymappings: - - {id: 15, cat: TV/Anime, desc: "Anime"} - - {id: 1, cat: Other, desc: "Other"} - - {id: 5, cat: Movies/DVD, desc: "Movies DVDR"} - - {id: 9, cat: TV/Documentary, desc: "TV - Documentaries"} - - {id: 12, cat: TV/HD, desc: "TV HD"} - - {id: 2, cat: TV/Sport, desc: "Sports"} - - {id: 6, cat: Movies, desc: "Movies"} - - {id: 10, cat: PC/Games, desc: "Games"} - - {id: 3, cat: PC/Mac, desc: "Mac"} - - {id: 7, cat: Movies/Other, desc: "Cartoons"} - - {id: 11, cat: Audio, desc: "Music"} - - {id: 14, cat: PC, desc: "Windows"} - - {id: 4, cat: XXX, desc: "XXX"} - - {id: 8, cat: TV, desc: "TV shows"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - allowrawsearch: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: table.main:contains("Login failed") - test: - path: index.php - selector: a[href^="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 1 = look in description - Lysing: "{{ if .Query.IMDBID }}1{{ else }}{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not return imdb links - - rows: - selector: table[class="torrentlist"] > tbody > tr:has(a[href*="details.php?id="]) - filters: - - name: andmatch - args: 55 - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: td:nth-child(2) - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - size: - selector: td:nth-child(7) - files: - selector: td:nth-child(4) - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - date: - selector: td:nth-child(6) - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/demonoid-clone.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/demonoid-clone.yml deleted file mode 100644 index 9d7544fe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/demonoid-clone.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: demonoid-clone -name: Demonoid Clone -description: "Demonoid Clone is a Public Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: public -encoding: UTF-8 -links: - - https://demonoid.nl/ - -caps: - categorymappings: - - {id: Movies, cat: Movies, desc: "Movies"} - - {id: TV Shows, cat: TV, desc: "TV Shows"} - - {id: Anime, cat: TV/Anime, desc: "Anime"} - - {id: Games, cat: PC/Games, desc: "Games"} - - {id: Music, cat: Audio, desc: "Music"} - - {id: Software, cat: PC/0day, desc: "Software"} - - {id: Books, cat: Books, desc: "Books"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -search: - paths: - # https://demonoid.nl/api/torrents.php?action=list&page=1&limit=100&search=720p - - path: api/torrents.php - response: - type: json - - inputs: - action: list - page: 1 - limit: 100 - search: "{{ .Keywords }}" - - keywordsfilters: - # replace spaces between words with percent wildcard - - name: re_replace - args: ["\\W+", "%"] - - rows: - selector: torrents - count: - selector: pagination.total - - fields: - category: - selector: category - title: - selector: name - download_link: - optional: true - selector: file_path - download: - text: "{{ if .Result.download_link }}/{{ .Result.download_link }}{{ else }}{{ end }}" - details: - selector: id - filters: - - name: prepend - args: "{{ .Config.sitelink }}}#/torrent/" - magnet: - optional: true - selector: magnet_link - date: - selector: created_at - filters: - - name: append - args: " -12:00" # NUT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: file_size - grabs: - selector: downloads - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/desitorrents-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/desitorrents-api.yml deleted file mode 100644 index 37ddff87..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/desitorrents-api.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -id: desitorrents-api -name: DesiTorrents (API) -description: "DesiTorrents is an INDIAN Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://torrent.desi/ -legacylinks: - - https://desitorrents.tv/ - - https://desitorrents.rocks/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your DesiTorrents account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts will be disabled after 3 months of inactivity, 1 month for new accounts. Members must generate some download or upload activity at least once every 3 months." - -login: - path: /api/v1/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/v1/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.5 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.5 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/devil-torrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/devil-torrents.yml deleted file mode 100644 index bc2963a5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/devil-torrents.yml +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: devil-torrents -name: Devil-Torrents -description: "Devil-Torrents is a POLISH Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: semi-private -encoding: UTF-8 -links: - - https://devil-torrents.pl/ - -caps: - categorymappings: - - {id: 1, cat: Movies/SD, desc: "Filmy XviD/DivX"} - - {id: 748, cat: Movies/UHD, desc: "Filmy 4K"} - - {id: 4, cat: Movies/BluRay, desc: "Filmy Blu-Ray/HD"} - - {id: 642, cat: Movies/HD, desc: "Filmy x264/h264"} - - {id: 596, cat: Movies/HD, desc: "Filmy x265/h265"} - - {id: 3, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 5, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 2, cat: Movies/HD, desc: "Filmy RMVB"} - - {id: 362, cat: Movies/SD, desc: "Filmy IVO"} - - {id: 7, cat: TV, desc: "TV/Seriale"} - - {id: 702, cat: Movies/SD, desc: "TS/CAM"} - - {id: 8, cat: XXX, desc: "Erotyka"} - - {id: 10, cat: Audio, desc: "Muzyka"} - - {id: 11, cat: PC, desc: "Programy"} - - {id: 12, cat: PC/Mobile-Other, desc: "GSM/PDA"} - - {id: 13, cat: Console, desc: "Konsole"} - - {id: 14, cat: PC/Games, desc: "Gry PC"} - - {id: 15, cat: Movies, desc: "Dla Dzieci"} - - {id: 16, cat: Books, desc: "Książki"} - - {id: 525, cat: PC/Mac, desc: "Mac"} - - {id: 18, cat: PC, desc: "Linux"} - - {id: 19, cat: TV/Sport, desc: "Sport"} - - {id: 699, cat: TV/Anime, desc: "Anime"} - - {id: 21, cat: Other, desc: "Inne"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - - name: info_flaresolverr - type: info_flaresolverr - - name: info_limit - type: info - label: "Download Limit" - default: "If your downloads fail then access the site with your browser and check your messages, you've likely exceeded your limit." - -login: - path: logowanie - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[src^="img.php?size=3"] - input: vImageCodP - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: "" - error: - - selector: div#center-side:contains("Logowanie nie") - test: - path: / - selector: a[href$="/logout.php"] - -search: - # https://devil-torrents.pl/szukaj.php?search=%&typ=torrent&c4=1&c748=1 - paths: - - path: "{{ if .Keywords }}szukaj.php{{ else }}files-torrent{{ end }}" - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - typ: "{{ if .Keywords }}torrent{{ else }}{{ end }}" - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ end }}" - - rows: - selector: table.test5 > tbody > tr > td > div[id]:has(a[href^="download/"]) - - fields: - category: - selector: div#kategoria-gatunek-1 - case: - "div#kategoria-gatunek-1:contains(\"Filmy XviD/DivX\")": 1 - "div#kategoria-gatunek-1:contains(\"Filmy 4K\")": 748 - "div#kategoria-gatunek-1:contains(\"Filmy Blu-Ray/HD\")": 4 - "div#kategoria-gatunek-1:contains(\"Filmy x264/h264\")": 642 - "div#kategoria-gatunek-1:contains(\"Filmy x265/h265\")": 596 - "div#kategoria-gatunek-1:contains(\"Filmy DVD\")": 3 - "div#kategoria-gatunek-1:contains(\"Filmy 3D\")": 5 - "div#kategoria-gatunek-1:contains(\"Filmy RMVB\")": 2 - "div#kategoria-gatunek-1:contains(\"Filmy IVO\")": 362 - "div#kategoria-gatunek-1:contains(\"TV/Seriale\")": 7 - "div#kategoria-gatunek-1:contains(\"TS/CAM\")": 702 - "div#kategoria-gatunek-1:contains(\"Erotyka\")": 8 - "div#kategoria-gatunek-1:contains(\"Muzyka\")": 10 - "div#kategoria-gatunek-1:contains(\"Programy\")": 11 - "div#kategoria-gatunek-1:contains(\"GSM/PDA\")": 12 - "div#kategoria-gatunek-1:contains(\"Konsole\")": 13 - "div#kategoria-gatunek-1:contains(\"Gry PC\")": 14 - "div#kategoria-gatunek-1:contains(\"Dla Dzieci\")": 15 - "div#kategoria-gatunek-1:contains(\"Książki\")": 16 - "div#kategoria-gatunek-1:contains(\"Mac\")": 525 - "div#kategoria-gatunek-1:contains(\"Linux\")": 18 - "div#kategoria-gatunek-1:contains(\"Sport\")": 19 - "div#kategoria-gatunek-1:contains(\"Anime\")": 699 - "div#kategoria-gatunek-1:contains(\"Inne\")": 21 - title_phase1: - selector: a[href^="/torrent/"] - attribute: title - title_multilang: - selector: a[href^="/torrent/"] - attribute: title - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="download/"] - attribute: href - poster: - selector: img.browse_poster - attribute: src - date: - selector: td.descr3 - filters: - - name: regexp - args: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td.descr3 - filters: - - name: regexp - args: (\d{1,4}\.\d{2}\s+?[T|G|M|k]B) - seeders: - selector: font[color="green"] > b, font[color="red"] > b - leechers: - selector: font[color="green"]:nth-of-type(2) > b, font[color="red"]:nth-of-type(2) > b - grabs: - selector: td.descr3 - filters: - - name: regexp - args: (\d{1,4}) razy - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/diablotorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/diablotorrent.yml deleted file mode 100644 index 139828ee..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/diablotorrent.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: diablotorrent -name: Diablo Torrent -description: "Diablo Torrent is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://diablotorrent.net/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - # Film - - {id: 49, cat: Movies/SD, desc: "Film > Cam", default: true} - - {id: 21, cat: Movies/SD, desc: "Film > Cam [magyar]", default: true} - - {id: 101, cat: Movies/SD, desc: "Film > SD/HUN", default: true} - - {id: 102, cat: Movies/SD, desc: "Film > SD/ENG", default: true} - - {id: 103, cat: Movies/HD, desc: "Film > HD/HUN", default: true} - - {id: 104, cat: Movies/HD, desc: "Film > HD/ENG", default: true} - - {id: 105, cat: Movies/DVD, desc: "Film > DVD-R/HUN", default: true} - - {id: 106, cat: Movies/DVD, desc: "Film > DVD-R/ENG", default: true} - # Sorozat - - {id: 107, cat: TV/SD, desc: "Sorozat > SD/HUN", default: true} - - {id: 108, cat: TV/SD, desc: "Sorozat > SD/ENG", default: true} - - {id: 109, cat: TV/SD, desc: "Sorozat > DVD-R/HUN", default: true} - - {id: 110, cat: TV/SD, desc: "Sorozat > DVD-R/ENG", default: true} - - {id: 111, cat: TV/HD, desc: "Sorozat > HD/HUN", default: true} - - {id: 112, cat: TV/HD, desc: "Sorozat > HD/ENG", default: true} - # Pornó - - {id: 34, cat: XXX/DVD, desc: "Pornó > DVD-R", default: false} - - {id: 36, cat: XXX, desc: "Pornó > Képek", default: false} - - {id: 113, cat: XXX/XviD, desc: "Pornó > SD", default: false} - - {id: 114, cat: XXX, desc: "Pornó > HD", default: false} - # Játék - - {id: 12, cat: PC/Games, desc: "Játék > PC", default: true} - - {id: 14, cat: Console/PS4, desc: "Játék > PS", default: true} - - {id: 39, cat: Console/XBox, desc: "Játék > Xbox", default: true} - - {id: 40, cat: Console/Other, desc: "Játék > Egyéb", default: true} - # Zene - - {id: 8, cat: Audio/MP3, desc: "Zene > MP3", default: true} - - {id: 9, cat: Audio/MP3, desc: "Zene > MP3 [magyar]", default: true} - - {id: 37, cat: Audio/Lossless, desc: "Zene > Lossless [magyar]", default: true} - - {id: 38, cat: Audio/Lossless, desc: "Zene > Lossless", default: true} - # Programok - - {id: 16, cat: PC/0day, desc: "Programok > PC", default: true} - - {id: 24, cat: PC/Mobile-Other, desc: "Programok > Mobil", default: true} - - {id: 53, cat: PC/0day, desc: "Programok > Egyéb", default: true} - # Egyéb - - {id: 27, cat: Other, desc: "Egyéb > !! Feltöltési segédlet !!", default: true} - - {id: 29, cat: Other, desc: "Egyéb > Képek", default: true} - # E-Book - - {id: 20, cat: Books, desc: "E-Book > Magyar", default: true} - - {id: 52, cat: Books, desc: "E-Book > Egyéb", default: true} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 7 - options: - 1: title - 7: created - 4: seeders - 2: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrent oldalanként: setting to 100 on your account profile. - -login: - path: login - submitpath: login/run - method: form - inputs: - referer: "" - nev: "{{ .Config.username }}" - jelszo: "{{ .Config.password }}" - fsv: 0 - login: 1 - belepes: 1 - rhash: 123 - error: - - selector: :root:contains("{\"hiba\":true,") - test: - path: browse - selector: a[href*="logout"] - -search: - paths: - - path: browse - categories: [49, 21, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 12, 14, 39, 40, 8, 9, 37, 38, 16, 24, 53, 27, 29, 20, 52] - - path: browse/1 - categories: [34, 36, 113, 114] - inputs: - $raw: "{{ range .Categories }}category[]={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - imdb: "{{ if .Query.IMDBID }}1{{ else }}{{ end }}" - s_alapjan: "{{ .Config.sort }}" - s_sorrend: "{{ .Config.type }}" - - rows: - selector: div.torrent_row, div.torrent_details - after: 1 - - fields: - category: - selector: div.box_category > img - attribute: onclick - filters: - - name: regexp - args: "category\\((\\d+),\\d+\\);" - title: - selector: a.box_name_a > span - details: - selector: a.box_name_a - attribute: href - download: - selector: div.box_download > a - attribute: href - seeders: - selector: div.box_seeders - leechers: - selector: div.box_leechers - grabs: - selector: div.box_completed - files: - selector: div.box_files - date: - selector: div.box_uploaded - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: div.box_size - poster: - selector: a.cover_a - attribute: title - filters: - - name: regexp - args: src='(.*?)' - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - description_text: - selector: div.torrent_row_magyarcim - remove: span.torrent_row_new - description: - case: - img[src="/pic/browse/yes.png"]: "Verified: {{ .Result.description_text}}" - "*": "Unverified: {{ .Result.description_text}}" - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/digitalcore-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/digitalcore-api.yml deleted file mode 100644 index 75055523..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/digitalcore-api.yml +++ /dev/null @@ -1,256 +0,0 @@ ---- -id: digitalcore-api -replaces: - - digitalcore -name: DigitalCore (API) -description: "DigitalCore (DC) is a Private Torrent Tracker for 0DAY / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://digitalcore.club/ - - https://prxy.digitalcore.club/ - -caps: - categorymappings: - - {id: 2, cat: Movies/SD, desc: Movies/SD} - - {id: 1, cat: Movies/DVD, desc: Movies/DVDR} - - {id: 5, cat: Movies/HD, desc: Movies/720p} - - {id: 6, cat: Movies/HD, desc: Movies/1080p} - - {id: 4, cat: Movies/UHD, desc: Movies/2160p} - - {id: 3, cat: Movies/BluRay, desc: Movies/BluRay} - - {id: 38, cat: Movies/UHD, desc: Movies/Bluray/UHD} - - {id: 7, cat: Movies/HD, desc: Movies/PACKS} - - {id: 10, cat: TV/SD, desc: Tv/SD} - - {id: 11, cat: TV/SD, desc: Tv/DVDR} - - {id: 8, cat: TV/HD, desc: Tv/720p} - - {id: 9, cat: TV/HD, desc: Tv/1080p} - - {id: 13, cat: TV/UHD, desc: Tv/2160p} - - {id: 14, cat: TV/HD, desc: Tv/BluRay} - - {id: 12, cat: TV/HD, desc: Tv/PACKS} - - {id: 15, cat: TV/Sport, desc: Tv/SPORTS} - - {id: 17, cat: Other, desc: Unknown} - - {id: 18, cat: PC/0day, desc: Apps/0DAY} - - {id: 20, cat: PC/ISO, desc: Apps/PC} - - {id: 21, cat: PC/Mac, desc: Apps/Mac} - - {id: 33, cat: PC, desc: Apps/Tutorials} - - {id: 22, cat: Audio/MP3, desc: Music/MP3} - - {id: 23, cat: Audio/Lossless, desc: Music/FLAC} - - {id: 24, cat: Audio/Video, desc: Music/MTV} - - {id: 29, cat: Audio, desc: Music/PACKS} - - {id: 39, cat: Audio/Video, desc: Music/DVD} - - {id: 40, cat: Audio/Video, desc: Music/Bluray} - - {id: 25, cat: PC/Games, desc: Games/PC} - - {id: 27, cat: PC/Mac, desc: Games/Mac} - - {id: 26, cat: Console, desc: Games/Consoles} - - {id: 43, cat: Console, desc: Games/ROMS} - - {id: 42, cat: XXX/Other, desc: Games/XXX} - - {id: 44, cat: Audio/Audiobook, desc: Audiobooks} - - {id: 28, cat: Books/EBook, desc: Ebooks} - - {id: 30, cat: XXX/SD, desc: XXX/SD} - - {id: 31, cat: XXX, desc: XXX/HD} - - {id: 32, cat: XXX/UHD, desc: XXX/4K} - - {id: 35, cat: XXX/SD, desc: XXX/Movies/SD} - - {id: 36, cat: XXX, desc: XXX/Movies/HD} - - {id: 37, cat: XXX/UHD, desc: XXX/Movies/4K} - - {id: 34, cat: XXX/ImageSet, desc: XXX/Imagesets} - - {id: 41, cat: XXX, desc: XXX/PACKS} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Generate a new API Key by accessing your DigitalCore account Settings page and clicking on the Security tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: p2p_releases_only - type: checkbox - label: Search P2P releases only - default: false - - name: unrar_releases_only - type: checkbox - label: Search UNRAR releases only - default: false - - name: sort - type: select - label: Sort requested from site - default: d - options: - d: created - up: seeders - s: size - n: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If your account is inactive (you do not sign in) for more than 90 days, your account is disabled. Park your account if you are going away for long periods." - -login: - method: get - path: /api/v1/torrents - -search: - paths: - - path: api/v1/torrents - response: - type: json - noResultsMessage: "" - - headers: - X-API-KEY: ["{{ .Config.apikey }}"] - - inputs: - $raw: "{{ range .Categories }}categories[]={{.}}&{{end}}" - dead: true - extendedDead: true - extendedSearch: false - freeleech: "{{ if .Config.freeleech }}true{{ else }}false{{ end }}" - index: 0 - limit: 100 - order: "{{ .Config.type }}" - p2p: "{{ if .Config.p2p_releases_only }}true{{ else }}{{ end }}" - page: search - searchText: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }} {{ else }}{{ end }}{{ .Keywords }}" - section: all - sort: "{{ .Config.sort }}" - stereoscopic: false - unrar: "{{ if .Config.unrar_releases_only }}true{{ else }}{{ end }}" - watchview: false - - rows: - selector: $ - filters: - - name: andmatch - - fields: - category: - selector: category - title: - selector: name - _id: - selector: id - details: - text: "/torrent/{{ .Result._id }}/" - download: - text: "/api/v1/torrents/download/{{ .Result._id }}" - imdbid: - selector: imdbid2 - imdbid_full: - optional: true - selector: imdbid2 - poster_normal: - optional: true - selector: firstpic - poster_imdb: - optional: true - text: "{{ if .Result.imdbid }}/img/imdb/{{ .Result.imdbid_full }}.jpg{{ else }}{{ end }}" - poster: - text: "{{ if .Result.poster_imdb }}{{ .Result.poster_imdb }}{{ else }}{{ .Result.poster_normal }}{{ end }}" - date: - # 2021-10-27 12:07:22 - selector: added - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: size - files: - selector: numfiles - grabs: - selector: times_completed - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - # api returns 0=false, 1=true - selector: frileech - case: - 0: 1 # not free - 1: 0 # freeleech - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 - title_desc: - optional: true - selector: title - year: - optional: true - selector: year - genre: - selector: genres - _tagline_desc: - optional: true - selector: tagline - _cast_desc: - optional: true - selector: cast - _rating_desc: - optional: true - selector: rating - filters: - - name: re_replace - args: ["^0$", ""] - _section_desc: - optional: true - selector: section - _predate_desc: - selector: preDate - filters: - - name: re_replace - args: ["0000-00-00 00:00:00", ""] - _p2ptag: - selector: p2p - case: - 0: "" - 1: P2P - _unrartag: - selector: unrar - case: - 0: "" - 1: UNRAR - optional: true - _packtag: - selector: pack - case: - 0: "" - 1: Pack - _reqidtag: - selector: reqid - case: - 0: "" - "*": Request - _tag_desc: - optional: true - text: "{{ if .Result._p2ptag }}{{ .Result._p2ptag }}, {{ else }}{{ end }}{{ if .Result._unrartag }}{{ .Result._unrartag }}, {{ else }}{{ end }}{{ if .Result._packtag }}{{ .Result._packtag }}, {{ else }}{{ end }}{{ if .Result._reqidtag }}{{ .Result._reqidtag }}{{ else }}{{ end }}" - filters: - - name: regexp - args: "(.+?), $" - description: - text: "{{ if .Result.title_desc }}Title: {{ .Result.title_desc }}
{{ else }}{{ end }}{{ if .Result.year }}Year: {{ .Result.year }}
{{ else }}{{ end }}{{ if .Result.genre }}Genres: {{ .Result.genre }}
{{ else }}{{ end }}{{ if .Result._tagline_desc }}Tagline: {{ .Result._tagline_desc }}
{{ else }}{{ end }}{{ if .Result._cast_desc }}Cast: {{ .Result._cast_desc }}
{{ else }}{{ end }}{{ if .Result._rating_desc }}Rating: {{ .Result._rating_desc }}
{{ else }}{{ end }}{{ if .Result._tag_desc }}Tags: {{ .Result._tag_desc }}
{{ else }}{{ end }}{{ if .Result._predate_desc }}PRE: {{ .Result._predate_desc }}
{{ else }}{{ end }}{{ if .Result._section_desc }}Section: {{ .Result._section_desc }}{{ else }}{{ end }}" -# json rartracker api v1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dimeadozen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dimeadozen.yml deleted file mode 100644 index ce41d1eb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dimeadozen.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: dimeadozen -name: DimeADozen -description: "DimeADozen (EzTorrent) is a Private Torrent Tracker for BOOTLEG MUSIC" -language: en-GB -type: private -encoding: UTF-8 -links: - - http://www.dimeadozen.org/ # site does not support https ERR_CONNECTION_REFUSED - -caps: - categorymappings: - - {id: 51, cat: Audio, desc: "Acoustic"} - - {id: 4, cat: Audio, desc: "Alternate"} - - {id: 47, cat: Audio, desc: "Alternate Country"} - - {id: 7, cat: Audio, desc: "Americana"} - - {id: 45, cat: Audio, desc: "Avantgarde"} - - {id: 23, cat: Audio, desc: "Bluegrass"} - - {id: 10, cat: Audio, desc: "Blues"} - - {id: 17, cat: Audio, desc: "Brit Pop"} - - {id: 42, cat: Audio, desc: "Celtic Soul"} - - {id: 24, cat: Audio, desc: "Chanson"} - - {id: 25, cat: Audio, desc: "Classic"} - - {id: 55, cat: Audio, desc: "Classical"} - - {id: 44, cat: Audio, desc: "Club Rock"} - - {id: 3, cat: Audio, desc: "Country"} - - {id: 41, cat: Audio, desc: "Dancefloor"} - - {id: 49, cat: Audio, desc: "Desert Surf"} - - {id: 48, cat: Audio, desc: "Drum'n'Bass"} - - {id: 53, cat: Audio, desc: "Electronica"} - - {id: 26, cat: Audio, desc: "Ethno"} - - {id: 9, cat: Audio, desc: "Folk"} - - {id: 12, cat: Audio, desc: "Funk"} - - {id: 19, cat: Audio, desc: "Garage Rock"} - - {id: 27, cat: Audio, desc: "Gospel"} - - {id: 28, cat: Audio, desc: "Gothic Rock"} - - {id: 40, cat: Audio, desc: "Grunge"} - - {id: 61, cat: Audio, desc: "Hard Rock"} - - {id: 59, cat: Audio, desc: "Hardcore"} - - {id: 34, cat: Audio, desc: "Heavy Metal"} - - {id: 29, cat: Audio, desc: "HipHop"} - - {id: 30, cat: Audio, desc: "House"} - - {id: 31, cat: Audio, desc: "Industrial"} - - {id: 57, cat: Audio, desc: "Irish Folk"} - - {id: 50, cat: Audio, desc: "Jam Bands"} - - {id: 2, cat: Audio, desc: "Jazz"} - - {id: 60, cat: Audio, desc: "Jazz Fusion"} - - {id: 18, cat: Audio, desc: "Kraut Rock"} - - {id: 32, cat: Audio, desc: "Latin Music"} - - {id: 33, cat: Audio, desc: "Mainstream"} - - {id: 46, cat: Audio, desc: "New German Wave"} - - {id: 5, cat: Audio, desc: "New Wave"} - - {id: 14, cat: Audio, desc: "Pop"} - - {id: 13, cat: Audio, desc: "Progressive Rock"} - - {id: 58, cat: Audio, desc: "Psychedelic"} - - {id: 6, cat: Audio, desc: "Punk"} - - {id: 35, cat: Audio, desc: "Rap"} - - {id: 36, cat: Audio, desc: "Rave"} - - {id: 21, cat: Audio, desc: "Reggae"} - - {id: 1, cat: Audio, desc: "Rock"} - - {id: 20, cat: Audio, desc: "Rock'n'Roll"} - - {id: 8, cat: Audio, desc: "Singer/Songwriter"} - - {id: 22, cat: Audio, desc: "Ska"} - - {id: 37, cat: Audio, desc: "Skiffle"} - - {id: 11, cat: Audio, desc: "Soul"} - - {id: 54, cat: Audio, desc: "Southern Rock"} - - {id: 38, cat: Audio, desc: "Swing"} - - {id: 39, cat: Audio, desc: "Techno"} - - {id: 43, cat: Audio, desc: "Tin Pan Alley"} - - {id: 52, cat: Audio, desc: "Unplugged"} - - {id: 15, cat: Audio, desc: "West Coast"} - - {id: 16, cat: Audio, desc: "World Music"} - - {id: 56, cat: Audio, desc: "Zydeco"} - - modes: - search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: take-login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: index.php - logout: "" - error: - - selector: table.message:contains("Error") - - selector: body:contains("Error 403") - test: - path: index.php - selector: a[href="account-logout.php"] - -download: - # download.php/673256/Talk%20Talk%20-%201986-07-05%20Paris.torrent - selectors: - - selector: a[href^="download.php/"] - attribute: href - -search: - paths: - # http://www.dimeadozen.org/torrents-browse.php?search=%2Btalk+%2Bparis&cat=0&incldead=1&searchscope=1 - - path: torrents-browse.php - inputs: - search: "{{ .Keywords }}" - # (empty) active only, 1 incldead - incldead: 1 - # (empty) search in title & filename & descr, 1 title & filename only - searchscope: 1 - keywordsfilters: - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - # does not support multi category filtering - # does not support imdbid searches, does not return imdb link in results - # does not support sorting - - rows: - selector: table.torrent > tbody > tr:has(a[href^="torrents-details.php?id="]) - - fields: - category: - selector: a[href^="torrents-browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="torrents-details.php?id="] - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="torrents-details.php?id="] - attribute: href - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " -00:00" # GMT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.25 -# EzTorrent 0.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/discfan.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/discfan.yml deleted file mode 100644 index e244b226..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/discfan.yml +++ /dev/null @@ -1,198 +0,0 @@ ---- -id: discfan -name: DiscFan -description: "DiscFan is a CHINESE Private Torrent Tracker for MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://discfan.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "中国大陆(CHN)"} - - {id: 404, cat: Movies, desc: "中国香港特别行政区(HKG)"} - - {id: 405, cat: Movies, desc: "中国台湾省(TWN)"} - - {id: 402, cat: Movies, desc: "泰国(THA)"} - - {id: 403, cat: Movies, desc: "日本(JPN)"} - - {id: 406, cat: Movies, desc: "韩国(KOR)"} - - {id: 410, cat: Movies, desc: "世界(World)"} - - {id: 411, cat: TV, desc: "剧集(Series)"} - - {id: 414, cat: Audio, desc: "音乐(Music)"} - - {id: 413, cat: TV/Documentary, desc: "记录(Documentary)"} - - {id: 416, cat: TV, desc: "综艺(Variety Show)"} - - {id: 417, cat: TV/Sport, desc: "体育(Sports)"} - - {id: 419, cat: TV/Anime, desc: "动漫(Animation)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失敗")) - message: - selector: td.text - test: - path: index.php - selector: a[href*="userdetails.php?id="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td:nth-child(6) - optional: true - default: 0 - leechers: - selector: td:nth-child(7) - optional: true - default: 0 - grabs: - selector: td:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - category_title: - selector: a[href^="?cat="] > img - attribute: title - description: - selector: td:nth-child(2) - remove: a, img - filters: - - name: prepend - args: "cat={{ .Result.category_title }} - " - minimumratio: - text: 0.9 -# NexusPHP v1.9.14 2026-01-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dmhy.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dmhy.yml deleted file mode 100644 index 9a7d7612..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dmhy.yml +++ /dev/null @@ -1,95 +0,0 @@ ---- -id: dmhy -name: dmhy -description: "dmhy is a TAIWANESE Public magnet tracker for ANIME" -language: zh-TW -type: public -encoding: UTF-8 -links: - - https://share.dmhy.org/ - -caps: - categorymappings: - - {id: 2, cat: TV/Anime, desc: "動畫 (Anime)"} - - {id: 31, cat: TV/Anime, desc: "季度全集 (Quarterly Complete)"} - - {id: 3, cat: Books/Comics, desc: "漫畫 (Manga)"} - - {id: 41, cat: TV, desc: "港台原版 (Hong Kong and Taiwan original)"} - - {id: 42, cat: TV, desc: "日文原版 (Japanese original)"} - - {id: 4, cat: Audio, desc: "音樂 (Music)"} - - {id: 43, cat: Audio, desc: "動漫音樂 (Anime Music)"} - - {id: 44, cat: Audio, desc: "同人音樂 (Fan Music)"} - - {id: 15, cat: Audio, desc: "流行音樂 (Pop Music)"} - - {id: 6, cat: TV, desc: "日劇 (Japanese TV drama)"} - - {id: 7, cat: TV/Anime, desc: "RAW"} - - {id: 9, cat: PC/Games, desc: "遊戲 (games)"} - - {id: 17, cat: PC/Games, desc: "電腦遊戲 (computer games)"} - - {id: 18, cat: Console, desc: "電視遊戲 (video games)"} - - {id: 19, cat: Console, desc: "掌機遊戲 (console games)"} - - {id: 20, cat: Console, desc: "網絡遊戲 (online games)"} - - {id: 21, cat: Console, desc: "遊戲周邊 (game periphery)"} - - {id: 12, cat: Other/Misc, desc: "特攝 (special photo)"} - - {id: 1, cat: Other, desc: "其他 (other)"} - # Anime as Movies (Radarr uses t=movie): - - {id: 2, cat: Movies/Other, desc: "動畫 (Anime)"} - - {id: 31, cat: Movies/Other, desc: "季度全集 (Quarterly Complete)"} - - {id: 7, cat: Movies/Other, desc: "RAW"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -search: - paths: - - path: topics/list - inputs: - # https://share.dmhy.org/topics/list?keyword=one+piece&sort_id=0&team_id=0&order=date-desc - keyword: "{{ .Keywords }}" - sort_id: 0 - order: date-desc - team_id: 0 - - rows: - selector: table tbody tr:has(a[href^="magnet:?"]) - - fields: - category: - selector: td:nth-child(2) a - attribute: href - filters: - - name: regexp - args: "\\/(\\d+)$" - title: - selector: a[href^="/topics/view/"] - details: - selector: a[href^="/topics/view/"] - attribute: href - download: - selector: a[href^="magnet:?"] - attribute: href - date: - selector: td:nth-child(1) span - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy/MM/dd HH:mm zzz" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# tedmind 1.8.1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/docspedia.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/docspedia.yml deleted file mode 100644 index 1205d49c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/docspedia.yml +++ /dev/null @@ -1,183 +0,0 @@ ---- -id: docspedia -name: DocsPedia -description: "DocsPedia is a Private Torrent Tracker for E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.docspedia.world/ - -caps: - categorymappings: - - {id: 77, cat: PC, desc: "3D Models"} - - {id: 83, cat: Books/EBook, desc: "Action/Adventure"} - - {id: 84, cat: Books/EBook, desc: "Art"} - - {id: 65, cat: Audio, desc: "Audio"} - - {id: 85, cat: Books/EBook, desc: "Biographical"} - - {id: 64, cat: Books/EBook, desc: "Books"} - - {id: 66, cat: Books, desc: "Business"} - - {id: 68, cat: Books/Comics, desc: "Comics"} - - {id: 80, cat: Books, desc: "Coooking"} - - {id: 86, cat: Books/EBook, desc: "Crafts"} - - {id: 92, cat: Books/EBook, desc: "Crime/Thriller"} - - {id: 59, cat: TV/Documentary, desc: "Docs/Movies"} - - {id: 60, cat: TV/Documentary, desc: "Docs/TV"} - - {id: 79, cat: TV, desc: "Educational"} - - {id: 93, cat: Books/EBook, desc: "Fantasy"} - - {id: 71, cat: Other, desc: "Fonts"} - - {id: 94, cat: Books/EBook, desc: "Gaming"} - - {id: 82, cat: Books/EBook, desc: "History"} - - {id: 87, cat: Books/EBook, desc: "Horror"} - - {id: 72, cat: Books, desc: "IT Books"} - - {id: 57, cat: Books/Mags, desc: "Magazine"} - - {id: 88, cat: Books/EBook, desc: "Math/Science/Tech"} - - {id: 58, cat: Books, desc: "Medicine"} - - {id: 95, cat: Books/EBook, desc: "Non-Fiction"} - - {id: 70, cat: Books/EBook, desc: "Occult"} - - {id: 81, cat: Books/EBook, desc: "Photography"} - - {id: 74, cat: Audio, desc: "Podcasts"} - - {id: 69, cat: Other, desc: "PS Resources"} - - {id: 91, cat: Books/EBook, desc: "Romance"} - - {id: 89, cat: Books/EBook, desc: "Scinece Fiction"} - - {id: 76, cat: Books, desc: "Self-Help"} - - {id: 56, cat: TV/Sport, desc: "Sports"} - - {id: 90, cat: Books/EBook, desc: "Travel"} - - {id: 61, cat: Other, desc: "Tutorials"} - - modes: - search: [q] - tv-search: [q, season, ep] - book-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts without activity in the first 28 days will be deleted automatically by the system." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img.cimage - input: captcha - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.subheader:contains("failed") - test: - path: my.php - selector: a[href$="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech, 4 480p, 5 720p, 6 1080p, 7 4k - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: div.torrentrow:has(a[href^="download.php?torrent="]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - files: - selector: div.torrenttable:nth-last-child(6) - size: - selector: div.torrenttable:nth-last-child(4) - grabs: - selector: div.torrenttable:nth-last-child(3) - seeders: - selector: div.torrenttable:nth-last-child(2) - leechers: - selector: div.torrenttable:nth-last-child(1) - downloadvolumefactor: - case: - img[src$="images/free.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - date_day: - remove: a - # Today 02:40 PM - # Yesterday 09:10 AM - selector: div.torrenttable:nth-child(2):contains("day") - # auto adjusted by site account profile - optional: true - date_year: - remove: a - # Jul 4 2019 08:04 AM - selector: div.torrenttable:nth-child(2):not(:contains("day")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM d yyyy hh:mm tt" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TBdev diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dreamingtree.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dreamingtree.yml deleted file mode 100644 index c3a68422..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dreamingtree.yml +++ /dev/null @@ -1,151 +0,0 @@ ---- -id: dreamingtree -name: DreamingTree -description: "DreamingTree is a Semi-Private Torrent Tracker for DAVE MATHEWS MUSIC / BOOTLEGS" -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://dreamingtree.org/ - -caps: - categorymappings: - - {id: 1, cat: Audio, desc: Music} - - modes: - search: [q] - music-search: [q, year, genre] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Covers per page: setting to 100 on your account profile. - -login: - path: login.php - method: form - form: form[action="login.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - login: "Log in" - error: - - selector: span.warning - message: - selector: span[class="warning"]:not(:contains("JavaScript")) - test: - path: index.php - selector: a[href^="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchstr: "{{ .Keywords }}" - # freetorrent: 0 normal, 1 freeleech, 2 neutral leech, 3 either - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - # we can only grab the first release if group_results=1 - group_results: 0 - action: advanced - year: "{{ .Query.Year }}" - taglist: "{{ .Query.Genre }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - searchsubmit: 1 - - rows: - selector: tr.torrent - - fields: - category: - text: 1 - title_artist: - selector: a[href^="artist.php?id="] - optional: true - filters: - - name: append - args: " - " - title_name: - selector: a[href^="torrents.php?id="] - year: - selector: div.group_info.clear - filters: - - name: regexp - args: "\\[(\\d{4})\\]" - title: - text: "{{ .Result.title_artist }}{{ .Result.title_name }} [{{ .Result.year }}]" - details: - selector: a[href^="torrents.php?id="] - attribute: href - poster: - selector: img - attribute: src - filters: - - name: replace - args: ["static/common/noartwork/music.png", ""] - genre: - selector: div.tags - description_info: - selector: div.torrent_info - description: - text: "{{ .Result.description_info }}
{{ .Result.genre }}" - download: - selector: span > a[href^="torrents.php?action=download&id="] - attribute: href - date: - selector: span.time.tooltip - filters: - - name: timeago - size: - selector: td.number_column.nobr - files: - selector: td:nth-last-child(6) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - grabs: - selector: td:nth-last-child(3) - downloadvolumefactor: - case: - div.torrent_info:contains("Freeleech"): 0 - div.torrent_info:contains("Neutral Leech"): 0 - "*": 1 - uploadvolumefactor: - case: - div.torrent_info:contains("Neutral Leech"): 0 - "*": 1 - minimumratio: - text: 0.6 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/drugari.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/drugari.yml deleted file mode 100644 index 043b1b8e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/drugari.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -id: drugari -name: Drugari -description: "Drugari is a CROATIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hr-HR -type: private -encoding: UTF-8 -links: - - http://drugari.org/ # does not support https, ERR_CONNECTION_CLOSED - -caps: - categorymappings: - - {id: 3, cat: TV/Anime, desc: "ANIMIRANI"} - - {id: 4, cat: PC, desc: "Appz"} - - {id: 1, cat: Movies, desc: "BEZ PRAVILA"} - - {id: 39, cat: Movies, desc: "BEZ PREVODA"} - - {id: 41, cat: Movies, desc: "BEZ PREVODA Filmovi"} - - {id: 40, cat: TV, desc: "BEZ PREVODA Serije"} - - {id: 7, cat: TV/Documentary, desc: "DOKUMENTARNI"} - - {id: 18, cat: Movies, desc: "DOMACI FILMOVI"} - - {id: 49, cat: Movies, desc: "DOMACI FILMOVI Predstava"} - - {id: 10, cat: Movies/DVD, desc: "DVD FILMOVI"} - - {id: 37, cat: TV, desc: "Emisije"} - - {id: 8, cat: Movies, desc: "FILM"} - - {id: 34, cat: Movies, desc: "FILM Kolekcije"} - - {id: 56, cat: Movies, desc: "FILM Kratki"} - - {id: 48, cat: Movies, desc: "FILM Ruski Film"} - - {id: 23, cat: Movies/SD, desc: "FILM TS"} - - {id: 24, cat: Movies, desc: "FILM TV RIP"} - - {id: 38, cat: Movies, desc: "FILM- Western"} - - {id: 22, cat: Movies/HD, desc: "FILM x264/x265-Filmovi"} - - {id: 21, cat: Movies/SD, desc: "FILM XviD/Strani Filmovi"} - - {id: 15, cat: Console, desc: "IGRE"} - - {id: 19, cat: Books, desc: "KNJIGE"} - - {id: 53, cat: Audio/Audiobook, desc: "KNJIGE Audio"} - - {id: 52, cat: Books/Mags, desc: "KNJIGE Casopisi"} - - {id: 55, cat: Books/EBook, desc: "KNJIGE pdf"} - - {id: 14, cat: Audio, desc: "MUZIKA"} - - {id: 50, cat: Audio, desc: "MUZIKA Domaca Diskografija"} - - {id: 29, cat: Audio, desc: "MUZIKA DVD Muzika"} - - {id: 33, cat: Audio/Lossless, desc: "MUZIKA FLAC"} - - {id: 35, cat: Audio, desc: "MUZIKA Karaoke"} - - {id: 32, cat: Audio/MP3, desc: "MUZIKA MP3"} - - {id: 31, cat: Audio/Other, desc: "MUZIKA Muzika Ostalo"} - - {id: 51, cat: Audio, desc: "MUZIKA Strana Diskografija"} - - {id: 30, cat: Audio/Video, desc: "MUZIKA Video Muzika"} - - {id: 11, cat: Other, desc: "RAZNO"} - - {id: 20, cat: TV, desc: "SERIJE"} - - {id: 27, cat: TV, desc: "SERIJE Domace Serije"} - - {id: 54, cat: TV, desc: "SERIJE Ruske Serije"} - - {id: 28, cat: TV, desc: "SERIJE Strane Serije"} - - {id: 16, cat: Other, desc: "SLIKE"} - - {id: 12, cat: TV/Sport, desc: "SPORT"} - - {id: 6, cat: Other, desc: "STRANI TORENTI"} - - {id: 5, cat: Other, desc: "STRIP"} - - {id: 13, cat: Other, desc: "UPUTSTVA"} - - {id: 25, cat: XXX, desc: "XXX"} - - {id: 47, cat: XXX, desc: "XXX XXX-Strip"} - - {id: 42, cat: TV, desc: "Za Decu"} - - {id: 43, cat: TV, desc: "Za Decu Edukativni Programi"} - - {id: 44, cat: Console, desc: "Za Decu Igrice"} - - {id: 45, cat: Books, desc: "Za Decu Knjige"} - - {id: 46, cat: Audio, desc: "Za Decu Muzika"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 40 on your account profile. The Default is 30. - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and read any new mail you may have received. You cannot browse torrents with unread mail outstanding." - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in to the Drugari web site with your browser once every 60 days to prevent your account from getting disabled." - -login: - path: login.php? - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img#regimage - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - error: - - selector: table:contains("Greska") - test: - path: index.php - selector: a[href*="/logout.php?logouthash="] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ .DownloadUri.Query.id }}" - selectors: - - selector: a[href*="download.php?id="] - attribute: href - -search: - paths: - - path: browse.php - keywordsfilters: - - name: re_replace - args: ["[\\s]+", "%"] - inputs: - do: search - keywords: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: "{{ if .Query.IMDBID }}t_genre{{ else }}t_name{{ end }}" - # does not support multi category searching so defaulting to all. - category: 0 - # yes, no - include_dead_torrents: yes - sort: "{{ if .Config.freeleech }}free{{ else }}{{ .Config.sort }}{{ end }}" - order: "{{ if .Config.freeleech }}asc{{ else }}{{ .Config.type }}{{ end }}" - # does not return imdb link in results - - rows: - selector: "table.sortable tr:has(a[href*=\"/download.php?id=\"]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="/browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - title_default: - # is usually abbreaviated - selector: a[href*="/details.php?id="] - title: - # while still abbreviated, is usually longer than the default - selector: div.tooltip-content div - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/details.php?id="] - attribute: href - poster: - selector: img[src*="/torrents/images/"] - attribute: src - date: - selector: td:nth-child(2) - # auto adjusted by site account profile - filters: - - name: regexp - args: "(\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2})" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - description: - # Wait: 4 h - # Cekanje: 4 h - selector: div.tooltip-content > div:last-child - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - img[src$="/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# TS Special Edition v.8.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ds-reloaded.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ds-reloaded.yml deleted file mode 100644 index 7df5259d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ds-reloaded.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: ds-reloaded -name: DS-Reloaded -description: "DS-Reloaded (DS.R) is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://ds-reloaded.com/ -legacylinks: - - https://dark-shadow.ml/ - - https://projekt.dark-shadow.me/ - - https://dark-shadow.me/ - -caps: - categorymappings: - - {id: 32, cat: Movies/HD, desc: "Filme > HD"} - - {id: 28, cat: Movies/SD, desc: "Filme > SD"} - - {id: 114, cat: Movies/UHD, desc: "Filme > UHD"} - - {id: 140, cat: Movies/BluRay, desc: "Filme > BluRay"} - - {id: 138, cat: Movies, desc: "Filme > Pack's"} - - {id: 147, cat: Movies/HD, desc: "Filme > h266/x266"} - - {id: 150, cat: Movies/Foreign, desc: "Filme > International"} - - {id: 57, cat: TV/HD, desc: "Serien > HD"} - - {id: 60, cat: TV/SD, desc: "Serien > SD"} - - {id: 139, cat: TV/UHD, desc: "Serien > UHD"} - - {id: 59, cat: TV, desc: "Serien > Pack's"} - - {id: 110, cat: Audio/MP3, desc: "Audio > MP3"} - - {id: 48, cat: Audio/Lossless, desc: "Audio > Flac"} - - {id: 52, cat: Audio, desc: "Audio > Pack"} - - {id: 63, cat: Audio/Audiobook, desc: "Audio > Hörbuch"} - - {id: 120, cat: Audio/Video, desc: "Audio > Videos"} - - {id: 12, cat: PC/0day, desc: "Apps > Windows"} - - {id: 125, cat: PC/Mac, desc: "Apps > Mac-OSX"} - - {id: 11, cat: PC/Mobile-Other, desc: "Apps > Mobil"} - - {id: 141, cat: PC/ISO, desc: "Apps > Other"} - - {id: 15, cat: TV/Documentary, desc: "Doku > SD"} - - {id: 14, cat: TV/Documentary, desc: "Doku > HD"} - - {id: 148, cat: TV/Documentary, desc: "Doku > UHD"} - - {id: 123, cat: TV/Documentary, desc: "Doku > Pack's"} - - {id: 17, cat: PC/Games, desc: "Spiel > PC"} - - {id: 24, cat: Console, desc: "Spiel > Konsole"} - - {id: 126, cat: Console/Other, desc: "Spiel > Mobil"} - - {id: 121, cat: Console, desc: "Spiel > Wimmelbild"} - - {id: 61, cat: TV/Sport, desc: "Sport > HD"} - - {id: 62, cat: TV/Sport, desc: "Sport > SD"} - - {id: 96, cat: Books/Mags, desc: "ePaper > Zeitungen"} - - {id: 136, cat: Books/EBook, desc: "ePaper > E-Book"} - - {id: 137, cat: Books/Comics, desc: "ePaper > Comics"} - - {id: 143, cat: XXX/x264, desc: "XXX > HD / SD"} - - {id: 149, cat: XXX/Other, desc: "XXX > Magazine"} - - {id: 75, cat: XXX/Pack, desc: "XXX > Pack's"} - - {id: 142, cat: XXX/ImageSet, desc: "XXX > Pic's"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_password - type: info - label: About Password Expiry - default: "DS.R has an 8 week Password Expiry regime. So if the Indexer has stopped working then the first thing you should do is check if your password has expired by login in at DS-Reloaded, and if necessary use the Passwort anfordern (Request password) button to renew it." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: onlyupload - type: checkbox - label: Search OnlyUpload - default: false - - name: info_free - type: info - label: About OnlyUpload at DS.R - default: "OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech)." - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts will be deactivated after 7 weeks and deleted after another 3 weeks." - -login: - path: login.php - method: form - form: form - inputs: - login_identifier: "{{ .Config.username }}" - password: "{{ .Config.password }}" - loggiin: Login - error: - - selector: div#login_error - - selector: div.error-message - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://ds-reloaded.com/selection.php?ajax=true&search=&page=1&free=yes&c32=1&c28=1 - - path: selection.php - response: - type: json - - inputs: - ajax: true - search: "{{ .Keywords }}" - page: 1 - # all=active+inactive, yes=active, no=inactive (the default is all, which ajax simply drops the parm) - # visible: all - # all=all+onlyup, yes=onlyup, no=without onlyup (the default is all, which ajax simply drops the parm) - free: "{{ if .Config.onlyupload }}yes{{ else }}{{ end }}" - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - - rows: - selector: $ - - fields: - categorydesc: - selector: category - filters: - - name: htmldecode - title: - selector: name - _id: - selector: id - details: - text: "details.php?id={{ .Result._id }}" - download: - selector: download_link - poster: - selector: cover - size: - selector: size - date: - # 25.12.2024 16:02 - selector: added - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm zzz" - seeders: - selector: seeders - leechers: - selector: leechers - _onlyup: - selector: free_button:contains(Only Upload) - optional: true - downloadvolumefactor: - text: "{{ if .Result._onlyup }}0{{ else }}1{{ end }}" - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1.5 days (as seconds = 1.5 x 24 x 60 x 60) - text: 129600 -# THX JSON diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dubhe.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dubhe.yml deleted file mode 100644 index fc956b1a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dubhe.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: dubhe -name: dubhe (天枢) -description: "dubhe (天枢) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://dubhe.site/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影/Movies"} - - {id: 402, cat: TV, desc: "剧集/TV Series"} - - {id: 403, cat: TV, desc: "综艺/TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentaries"} - - {id: 405, cat: TV/Anime, desc: "动漫/Anime"} - - {id: 406, cat: Audio/Video, desc: "音乐视频/Music Videos"} - - {id: 407, cat: TV/Sport, desc: "体育运动/Sports"} - - {id: 408, cat: Audio, desc: "高品质音频/HQ Audio"} - - {id: 409, cat: Other/Misc, desc: "其他/Other"} - - {id: 410, cat: Books, desc: "书籍/Books"} - - {id: 411, cat: Other, desc: "照片/Photo"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dxp.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dxp.yml deleted file mode 100644 index fd406a8e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/dxp.yml +++ /dev/null @@ -1,221 +0,0 @@ ---- -id: dxp -name: DXP -description: "Deaf Experts (DXP) is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV with Russian Subtitles" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://dxp.ru/ - -caps: - categorymappings: - - {id: 18, cat: Other, desc: "Эксклюзивы"} - - {id: 17, cat: Other/Misc, desc: "VIP"} - - {id: 25, cat: Other, desc: "OnLine"} - - {id: 1, cat: PC, desc: "Приложения Windows"} - - {id: 2, cat: PC/Mobile-Other, desc: "Приложения PDA"} - - {id: 3, cat: PC, desc: "Приложения Unix/Linux"} - - {id: 23, cat: PC/Games, desc: "Игры Unix/Linux"} - - {id: 4, cat: PC/Mac, desc: "Приложения Mac"} - - {id: 5, cat: PC/Games, desc: "Игры PC"} - - {id: 6, cat: Console/PS3, desc: "Игры PlayStation 3"} - - {id: 7, cat: Console/XBox, desc: "Игры X-Box"} - - {id: 8, cat: Console/PSP, desc: "Игры PSP"} - - {id: 9, cat: TV/Documentary, desc: "Документация"} - - {id: 10, cat: Audio, desc: "Музыка"} - - {id: 11, cat: TV, desc: "Сериалы"} - - {id: 12, cat: Movies/Other, desc: "Мультфильмы"} - - {id: 14, cat: Movies/HD, desc: "Фильмы HDTV"} - - {id: 15, cat: Movies/DVD, desc: "Фильмы DVD"} - - {id: 13, cat: Movies/SD, desc: "Фильмы XviD"} - - {id: 16, cat: Books, desc: "Книги"} - - {id: 20, cat: Other, desc: "DEMO"} - - {id: 21, cat: Other, desc: "Лавка субтитров"} - - {id: 22, cat: Other, desc: "Картинки"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Торрентов на страницу setting to 100 on your account profile. - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - login: submit - error: - - selector: div.error - test: - path: index.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - # https://dxp.ru/torrents.php?search=&sort=4&type=desc&wh=name&incldead=1 - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - wh: name - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: div.torrent-item - - fields: - categorydesc: - selector: div.torrent-badge - title: - selector: h3.full-title - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="content.php?id="] - attribute: href - download: - selector: a[href^="content.php?id="] - attribute: href - poster: - selector: a[href^="content.php?id="] img - attribute: src - date: - selector: span.date-bottom - attribute: title - filters: - - name: replace - args: ["Обновлен: ", ""] - - name: replace - args: ["Добавлен: ", ""] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "dd.MM.yyyy HH:mm zzz" - size: - selector: div.info-row:has(i.fa-database) - grabs: - selector: span.completed-bottom - seeders: - selector: span.seeders-bottom - leechers: - selector: span.leechers-bottom - genre: - selector: div.info-row:has(i.fa-tags) - description: - selector: div.info-row:has(i.fa-globe) - filters: - - name: append - args: ": {{ .Result.genre }}" - downloadvolumefactor: - case: - div.category-badge:contains("VIP"): 1 # vip - "*": 0 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.3 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ebookbay.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ebookbay.yml deleted file mode 100644 index debeb9d9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ebookbay.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: ebookbay -name: EBookBay -description: "EBook Bay (EBB) is a Public Torrent Tracker for E-BOOKS" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -certificates: - - 1dad798f690521cfb37d6817b66f1f08fe8c8f36 # using CA from audiobookbay.lu (sister site) -links: - - https://ebb.la/ -legacylinks: - - http://ebb.la/ - -caps: - categorymappings: - - {id: "Action/Adventure", cat: Books/EBook, desc: "Action/Adventure"} - - {id: "Animals", cat: Books/EBook, desc: "Animals"} - - {id: "Arts", cat: Books/EBook, desc: "Arts"} - - {id: "Audio Books", cat: Audio/Audiobook, desc: "Audio Books"} - - {id: "Beauty", cat: Books/EBook, desc: "Beauty"} - - {id: "Business", cat: Books/EBook, desc: "Business"} - - {id: "Certification", cat: Books/EBook, desc: "Certification"} - - {id: "Children/Kids", cat: Books/EBook, desc: "Children/Kids"} - - {id: "Classic/Literary", cat: Books/EBook, desc: "Classic/Literary"} - - {id: "Comics", cat: Books/Comics, desc: "Comics"} - - {id: "Computer", cat: Books/EBook, desc: "Computer"} - - {id: "Contemporary", cat: Books/EBook, desc: "Contemporary"} - - {id: "Cooking", cat: Books/EBook, desc: "Cooking"} - - {id: "Crafts & Hobbies", cat: Books/EBook, desc: "Crafts & Hobbies"} - - {id: "Education", cat: Books/EBook, desc: "Education"} - - {id: "Entertainment", cat: Books/EBook, desc: "Entertainment"} - - {id: "Fantasy", cat: Books/EBook, desc: "Fantasy"} - - {id: "Gardening", cat: Books/EBook, desc: "Gardening"} - - {id: "Health", cat: Books/EBook, desc: "Health"} - - {id: "Historical Fiction", cat: Books/EBook, desc: "Historical Fiction"} - - {id: "History", cat: Books/EBook, desc: "History"} - - {id: "Horror", cat: Books/EBook, desc: "Horror"} - - {id: "Humorous", cat: Books/EBook, desc: "Humorous"} - - {id: "Internet", cat: Books/EBook, desc: "Internet"} - - {id: "Magazine", cat: Books/EBook, desc: "Magazine"} - - {id: "Marketing", cat: Books/EBook, desc: "Marketing"} - - {id: "Medical", cat: Books/EBook, desc: "Medical"} - - {id: "Mystery/Suspense", cat: Books/EBook, desc: "Mystery/Suspense"} - - {id: "Nonfiction", cat: Books/EBook, desc: "Nonfiction"} - - {id: "Novel", cat: Books/EBook, desc: "Novel"} - - {id: "Other", cat: Books/EBook, desc: "Other"} - - {id: "Paranormal", cat: Books/EBook, desc: "Paranormal"} - - {id: "Political", cat: Books/EBook, desc: "Political"} - - {id: "Real Estate", cat: Books/EBook, desc: "Real Estate"} - - {id: "Reference", cat: Books/EBook, desc: "Reference"} - - {id: "Religion", cat: Books/EBook, desc: "Religion"} - - {id: "Romance", cat: Books/EBook, desc: "Romance"} - - {id: "Sci-Fi", cat: Books/EBook, desc: "Sci-Fi"} - - {id: "Science", cat: Books/EBook, desc: "Science"} - - {id: "Self-Help", cat: Books/EBook, desc: "Self-Help"} - - {id: "Society", cat: Books/EBook, desc: "Society"} - - {id: "Software", cat: Books/EBook, desc: "Software"} - - {id: "Sports", cat: Books/EBook, desc: "Sports"} - - {id: "Technical", cat: Books/EBook, desc: "Technical"} - - {id: "Teen/Young Adult", cat: Books/EBook, desc: "Teen/Young Adult"} - - {id: "Textbook", cat: Books/EBook, desc: "Textbook"} - - {id: "Thriller", cat: Books/EBook, desc: "Thriller"} - - {id: "Travel", cat: Books/EBook, desc: "Travel"} - - {id: "Tutorial", cat: Books/EBook, desc: "Tutorial"} - - {id: "Western", cat: Books/EBook, desc: "Western"} - - modes: - search: [q] - book-search: [q] - -settings: [] - -download: - infohash: - hash: - selector: td:contains("Info Hash:") ~ td - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: div#content > div.poststuff > div.postname - filters: - - name: trim - - name: validfilename - -search: - paths: - # with just 5 results per page, try to grab up to 25 results - # https://ebb.la/?s=teeth - # https://ebb.la/page/2/?s=teeth - - path: / - - path: "page/2/" - - path: "page/3/" - - path: "page/4/" - - path: "page/5/" - inputs: - s: "{{ .Keywords }}" - - headers: - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: "div#content > div.poststuff:has(~ div.entry a.download), div#content > div.poststuff ~ div.entry:has(a.download)" - after: 1 - filters: - - name: andmatch - - fields: - category: - selector: span.writer - optional: true - default: Other - filters: - - name: re_replace - args: ["(?i)\\bEbooks?\\b", ""] - - name: regexp - args: "E book under:\\s+(.+?)\\s?\\\xA0" - title: - selector: div.postname a - details: - selector: a.detail - attribute: href - filters: - - name: replace - args: ["http:", "https:"] - download: - selector: a.detail - attribute: href - filters: - - name: replace - args: ["http:", "https:"] - poster: - selector: img[src^="http"]:not(img[src*="images/default_cover.jpg"]) - attribute: src - date: - text: now - size: - selector: p:contains("File Size") - optional: true - default: 0 - filters: - - name: regexp - args: "File Size: (.+?)s?$" - seeders: - selector: p:contains("Seeds") - optional: true - default: 0 - filters: - - name: regexp - args: "Seeds: (\\d+)" - leechers: - selector: p:contains("Peers") - optional: true - default: 0 - filters: - - name: regexp - args: "Peers: (\\d+)" - grabs: - selector: p:contains("Completed Downloads") - optional: true - default: 0 - filters: - - name: regexp - args: "Completed Downloads: (\\d+)" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# WordPress 2.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ebooks-shares.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ebooks-shares.yml deleted file mode 100644 index 0cc2e085..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ebooks-shares.yml +++ /dev/null @@ -1,243 +0,0 @@ ---- -id: ebooks-shares -name: Ebooks-Shares -description: "Ebooks-Shares is a Private Torrent Tracker for EBOOKS / AUDIOBOOKS" -language: en-US -type: private -encoding: UTF-8 -testlinktorrent: false -links: - - https://ebooks-shares.org/ - -caps: - categorymappings: - # Audio - - {id: 293, cat: Audio/Audiobook, desc: "Audio: Fiction"} - - {id: 294, cat: Audio/Audiobook, desc: "Audio:Sci-Fi/Fantasy"} - - {id: 295, cat: Audio/Audiobook, desc: "Audio:Crime/Thriller"} - - {id: 296, cat: Audio/Audiobook, desc: "Audio:Horror"} - - {id: 297, cat: Audio/Audiobook, desc: "Audio:General"} - - {id: 302, cat: Audio/Audiobook, desc: "Audio: (TTC,TMS,TGC)"} - - {id: 303, cat: Audio/Audiobook, desc: "Audio:Autobiography "} - - {id: 308, cat: Audio/Audiobook, desc: "Audio:Collections"} - - {id: 309, cat: Audio/Audiobook, desc: "Audio:History"} - - {id: 310, cat: Audio/Audiobook, desc: "Audio:Children/YA"} - - {id: 311, cat: Audio/Audiobook, desc: "Audio:Romance"} - - {id: 317, cat: Audio/Audiobook, desc: "Audio:Urbn Fant/para"} - - {id: 319, cat: Audio/Audiobook, desc: "Audio:V.I.P"} - - {id: 320, cat: Audio/Audiobook, desc: "Audio:Science/engine"} - - {id: 321, cat: Audio/Audiobook, desc: "Audio:History/War"} - - {id: 322, cat: Audio/Audiobook, desc: "Audio:non fiction"} - - {id: 323, cat: Audio/Audiobook, desc: "Audio:Educational"} - - {id: 324, cat: Audio/Audiobook, desc: "Audio:Tutorials"} - - {id: 325, cat: Audio/Audiobook, desc: "Audio:True Crime"} - - {id: 326, cat: Audio/Audiobook, desc: "Audio:Tech/Computers"} - # Collections - - {id: 305, cat: Books/EBook, desc: "Same Author"} - - {id: 345, cat: Books/Mags, desc: "Collection: Magazines"} - - {id: 307, cat: Books/EBook, desc: "Collection: Military"} - - {id: 342, cat: Books/EBook, desc: "Collection: Multiple Authors"} - - {id: 343, cat: Books/EBook, desc: "Collection: Fantasy"} - - {id: 359, cat: Books/EBook, desc: "Collection: Same Series"} - - {id: 364, cat: Books/EBook, desc: "Collection: Multiple: Fiction"} - - {id: 365, cat: Books/EBook, desc: "Collection: Multiple:Non Fiction"} - # Fiction - - {id: 281, cat: Books/EBook, desc: "Horror"} - - {id: 282, cat: Books/EBook, desc: "Sci-Fi/Fantasy"} - - {id: 275, cat: Books/EBook, desc: "General Fiction"} - - {id: 280, cat: Books/EBook, desc: "Crime/Thriller/Myste"} - - {id: 312, cat: Books/EBook, desc: "Romance"} - - {id: 313, cat: Books/EBook, desc: "Comedy/Humour"} - - {id: 314, cat: Books/EBook, desc: "Historical Fiction"} - - {id: 315, cat: Books/EBook, desc: "Cozy/Mystery "} - - {id: 318, cat: Books/EBook, desc: "Urbn Fantasy/Para"} - - {id: 340, cat: Books/EBook, desc: "Lit/RPG"} - - {id: 341, cat: Books/EBook, desc: "Collections"} - - {id: 352, cat: Books/EBook, desc: "Westerns"} - # Children & Y/A - - {id: 299, cat: Books/EBook, desc: "Young Adult"} - - {id: 298, cat: Books/EBook, desc: "Children's"} - # Education/Textbooks - - {id: 278, cat: Books/EBook, desc: "Tutorials"} - - {id: 300, cat: Books/EBook, desc: "Textbooks"} - - {id: 301, cat: Books/EBook, desc: "Educational"} - - {id: 328, cat: Books/EBook, desc: "Engineering/Mech"} - - {id: 339, cat: Books/EBook, desc: "Science/Physics"} - - {id: 346, cat: Books/EBook, desc: "Chemistry"} - - {id: 347, cat: Books/EBook, desc: "Medical"} - - {id: 348, cat: Books/EBook, desc: "Mathematics"} - - {id: 363, cat: Books/EBook, desc: "Sheet Music"} - - {id: 366, cat: Books/EBook, desc: "Astronomy"} - # Tech/Computers - - {id: 331, cat: Books/EBook, desc: "Electronics"} - - {id: 336, cat: Books/EBook, desc: "Video Games"} - - {id: 337, cat: Books/EBook, desc: "Computers"} - - {id: 350, cat: Books/EBook, desc: "Programming"} - - {id: 354, cat: Books/EBook, desc: "Apps"} - # Hobbies/pastimes - - {id: 271, cat: Books/EBook, desc: "Food And Drink"} - - {id: 272, cat: Books/EBook, desc: "General Hobbies"} - - {id: 327, cat: Books/EBook, desc: "Gardening"} - - {id: 329, cat: Books/EBook, desc: "Motorcars/Repairs"} - - {id: 330, cat: Books/EBook, desc: "D.I.Y"} - - {id: 332, cat: Books/EBook, desc: "Trivia/Quiz"} - - {id: 367, cat: Books/EBook, desc: "Health & Fitness"} - - {id: 369, cat: Books/EBook, desc: "Woodworking"} - # Non Fict/Ref - - {id: 285, cat: Books/EBook, desc: "General Non-fiction"} - - {id: 289, cat: Books/EBook, desc: "Autobiography/Memoir"} - - {id: 274, cat: Books/EBook, desc: "Business"} - - {id: 316, cat: Books/EBook, desc: "True Crime"} - - {id: 335, cat: Books/EBook, desc: "Entertainment"} - - {id: 338, cat: Books/EBook, desc: "Encyclopedia/Ref"} - - {id: 349, cat: Books/EBook, desc: "Self Help"} - - {id: 351, cat: Books/EBook, desc: "Religion"} - - {id: 358, cat: Books/EBook, desc: "History"} - - {id: 362, cat: Books/EBook, desc: "Politics"} - # Military - - {id: 287, cat: Books/EBook, desc: "WW1"} - - {id: 288, cat: Books/EBook, desc: "WW2"} - - {id: 12, cat: Books/EBook, desc: "Army,Navy,Airforce"} - - {id: 286, cat: Books/EBook, desc: "General Military "} - # Zoology - - {id: 334, cat: Books/EBook, desc: "Veterinary/Pets"} - - {id: 361, cat: Books/EBook, desc: "Nature And Wildlife"} - # Art & Crafts - - {id: 344, cat: Books/EBook, desc: "Model Making"} - - {id: 357, cat: Books/EBook, desc: "Art"} - # Comics - - {id: 290, cat: Books/Comics, desc: "General Comics"} - - {id: 291, cat: Books/Comics, desc: "Marvel"} - - {id: 292, cat: Books/Comics, desc: "D/C"} - # Magazines - - {id: 306, cat: Books/Mags, desc: "Music"} - - {id: 10, cat: Books/Mags, desc: "Language Learn"} - - {id: 13, cat: Books/Mags, desc: "Non English Bks"} - - {id: 16, cat: Books/Mags, desc: "VIP"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: vip - type: checkbox - label: Include VIP results - default: true - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "All members are required to log into their account at least every 89 days or you will be deleted without warning." - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: tr td span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - # https://ebooks-shares.org/index.php?page=torrents&search=&category=0&active=0 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - # does not support multi category selection so defaulting to all - category: 0 - # 0 all, 1 active only, 2 dead only - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: "table.lista:not(table[cellspacing]) > tbody > tr:has(a[href^=\"index.php?page=torrent-details&id=\"]){{ if .Config.vip }}{{ else }}:not(:has(a[href$=\"&category=16\"])):not(:has(a[href$=\"&category=319\"])){{ end }}" - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - _is_vip: - selector: a[href$="&category=16"], a[href$="&category=319"] - attribute: href - optional: true - title: - selector: a[href^="index.php?page=torrent-details&id="] - filters: - - name: append - args: "{{ if .Result._is_vip }} VIP{{ else }}{{ end }}" - details: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: href - download: - selector: a[href^="index.php?page=downloadcheck"] - attribute: href - poster: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.*?) " - - name: replace - args: ["torrentimg/nocover.jpg", ""] - size: - selector: td:nth-last-child(4) - date: - selector: td:nth-last-child(8) - # auto adjusted by site account profile - filters: - - name: dateparse - args: dd/MM/yyyy - seeders: - selector: td:nth-last-child(7) - leechers: - selector: td:nth-last-child(6) - grabs: - selector: td:nth-last-child(5) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# xbtitFM v4.1.31 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ehentai.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ehentai.yml deleted file mode 100644 index 613df96c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ehentai.yml +++ /dev/null @@ -1,69 +0,0 @@ ---- -id: ehentai -name: E-Hentai -description: "E-Hentai is a Public site for Hentai doujinshi, manga." -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://e-hentai.org/ - -settings: [] - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - modes: - search: [q] - tv-search: [q, season, ep] - -download: - selectors: - - selector: a[href*="/get/"] - attribute: href - -search: - paths: - - path: torrents.php - - path: torrents.php - inputs: - page: 1 - inputs: - search: "{{ .Keywords }}" - - rows: - selector: table.itg > tbody > tr:has(td) - - fields: - category: - text: 1 - title: - selector: a[href*="/gallerytorrents.php?gid="] - details: - selector: a[href*="/g/"] - attribute: href - download: - selector: a[href*="/gallerytorrents.php?gid="] - attribute: href - date: - selector: td:first-child - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm zzz" - size: - selector: td:nth-child(4) - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/electro-torrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/electro-torrent.yml deleted file mode 100644 index d9bd4c09..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/electro-torrent.yml +++ /dev/null @@ -1,198 +0,0 @@ ---- -id: electro-torrent -name: Electro-Torrent -description: "Electro-Torrent is a POLISH Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: semi-private -encoding: UTF-8 -links: - - https://electro-torrent.pl/ -legacylinks: - - http://electro-torrent.pl/ - -caps: - categorymappings: - # from https://electro-torrent.pl/szukaj.php - # don't forget to update the category case block below - - {id: 1298, cat: Movies/SD, desc: "Filmy - Zwiastuny"} - - {id: 1, cat: Movies/SD, desc: "Filmy XviD/DivX"} - - {id: 769, cat: Movies/HD, desc: "Filmy 1080p AVC"} - - {id: 770, cat: Movies/HD, desc: "Filmy x264/1080p"} - - {id: 4, cat: Movies/HD, desc: "Filmy x264/720p"} - - {id: 642, cat: Movies/HD, desc: "Filmy x264/h264"} - - {id: 723, cat: Movies/UHD, desc: "Filmy 4K UHD"} - - {id: 1160, cat: Movies/UHD, desc: "Filmy x265/2160p"} - - {id: 1116, cat: Movies/HD, desc: "Filmy x265/1080p"} - - {id: 1204, cat: Movies/HD, desc: "Filmy x265/720p"} - - {id: 596, cat: Movies/HD, desc: "Filmy x265/h265"} - - {id: 1072, cat: Movies, desc: "Filmy - WAREZY"} - - {id: 3, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 5, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 362, cat: Movies/SD, desc: "Filmy IVO"} - - {id: 696, cat: Movies/SD, desc: "Filmy TS/CAM"} - - {id: 1255, cat: TV, desc: "TV/Seriale - Warezy"} - - {id: 7, cat: TV, desc: "TV/Seriale"} - - {id: 8, cat: XXX, desc: "Erotyka"} - - {id: 10, cat: Audio, desc: "Muzyka"} - - {id: 11, cat: PC, desc: "Programy"} - - {id: 12, cat: PC/Mobile-Other, desc: "GSM/PDA"} - - {id: 13, cat: Console, desc: "Konsole"} - - {id: 14, cat: PC/Games, desc: "Gry PC"} - - {id: 1045, cat: PC/Games, desc: "Gry PC - Warezy"} - - {id: 15, cat: Movies, desc: "Dla Dzieci"} - - {id: 16, cat: Books, desc: "Książki"} - - {id: 525, cat: PC/Mac, desc: "Mac"} - - {id: 18, cat: PC, desc: "Linux"} - - {id: 19, cat: TV/Sport, desc: "Sport"} - - {id: 907, cat: TV/Anime, desc: "Anime"} - - {id: 21, cat: Other, desc: "Inne"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - - name: info_limit - type: info - label: "Download Limit" - default: "Electro-Torrent has a limit on downloads. If downloads fail then you have exceeded your limit. Try again after a few hours." - -login: - path: logowanie - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[src^="/img.php?size=3"] - input: vImageCodP - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: "" - error: - - selector: div#center-side:contains("Logowanie nie") - test: - path: / - selector: a[href$="/logout.php"] - -search: - # http://electro-torrent.pl/szukaj.php?search=&typ=torrent&cat=0 - paths: - - path: szukaj.php - allowEmptyInputs: true - inputs: - # does not support multi cat selection, defaulting to all - # $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - cat: 0 - typ: torrent - search: "{{ .Keywords }}" - - rows: - selector: table.test5 > tbody > tr > td > div[id]:has(a[href*="/download/"]) - - fields: - category: - selector: div#kategoria-gatunek-1 - case: - "div#kategoria-gatunek-1:contains(\"Filmy - Zwiastuny\")": 1298 - "div#kategoria-gatunek-1:contains(\"Filmy XviD/DivX\")": 1 - "div#kategoria-gatunek-1:contains(\"Filmy 1080p AVC\")": 769 - "div#kategoria-gatunek-1:contains(\"Filmy x264/1080p\")": 770 - "div#kategoria-gatunek-1:contains(\"Filmy x264/720p\")": 4 - "div#kategoria-gatunek-1:contains(\"Filmy x264/h264\")": 642 - "div#kategoria-gatunek-1:contains(\"Filmy 4K UHD\")": 723 - "div#kategoria-gatunek-1:contains(\"Filmy x265/2160p\")": 1160 - "div#kategoria-gatunek-1:contains(\"Filmy x265/1080p\")": 1116 - "div#kategoria-gatunek-1:contains(\"Filmy x265/720p\")": 1204 - "div#kategoria-gatunek-1:contains(\"Filmy x265/h265\")": 596 - "div#kategoria-gatunek-1:contains(\"Filmy - WAREZY\")": 1072 - "div#kategoria-gatunek-1:contains(\"Filmy DVD\")": 3 - "div#kategoria-gatunek-1:contains(\"Filmy 3D\")": 5 - "div#kategoria-gatunek-1:contains(\"Filmy IVO\")": 362 - "div#kategoria-gatunek-1:contains(\"Filmy TS/CAM\")": 696 - "div#kategoria-gatunek-1:contains(\"TV/Seriale - Warezy\")": 1255 - "div#kategoria-gatunek-1:contains(\"TV/Seriale\")": 7 - "div#kategoria-gatunek-1:contains(\"Erotyka\")": 8 - "div#kategoria-gatunek-1:contains(\"Muzyka\")": 10 - "div#kategoria-gatunek-1:contains(\"Programy\")": 11 - "div#kategoria-gatunek-1:contains(\"GSM/PDA\")": 12 - "div#kategoria-gatunek-1:contains(\"Konsole\")": 13 - "div#kategoria-gatunek-1:contains(\"Gry PC - Warezy\")": 1045 - "div#kategoria-gatunek-1:contains(\"Gry PC\")": 14 - "div#kategoria-gatunek-1:contains(\"Dla Dzieci\")": 15 - "div#kategoria-gatunek-1:contains(\"Książki\")": 16 - "div#kategoria-gatunek-1:contains(\"Mac\")": 525 - "div#kategoria-gatunek-1:contains(\"Linux\")": 18 - "div#kategoria-gatunek-1:contains(\"Sport\")": 19 - "div#kategoria-gatunek-1:contains(\"Anime\")": 907 - "div#kategoria-gatunek-1:contains(\"Inne\")": 21 - title_phase1: - selector: a[href^="/torrent/"] - attribute: title - title_multilang: - selector: a[href^="/torrent/"] - attribute: title - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href*="/download/"] - attribute: href - poster: - selector: img.browse_poster - attribute: src - date: - selector: td.descr3 - filters: - - name: regexp - args: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td.descr3 - filters: - - name: regexp - args: (\d{1,4}\.\d{2}\s+?[T|G|M|k]B) - seeders: - selector: font[color="green"] > b, font[color="red"] > b - leechers: - selector: font[color="green"]:nth-of-type(2) > b, font[color="red"]:nth-of-type(2) > b - grabs: - selector: td.descr3 - filters: - - name: regexp - args: (\d{1,4}) razy - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/elitetorrent-wf.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/elitetorrent-wf.yml deleted file mode 100644 index 3cf12370..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/elitetorrent-wf.yml +++ /dev/null @@ -1,131 +0,0 @@ ---- -id: elitetorrent-wf -name: Elitetorrent-wf -description: "Elitetorrent-wf is a SPANISH Public tracker for MOVIES / TV" -language: es-ES -type: public -encoding: UTF-8 -links: - - https://www.elitetorrent.wf/ - -caps: - categories: - series: TV - peliculas: Movies - other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: [] - -download: - selectors: - - selector: a[href^="magnet:?"] - attribute: href - -search: - paths: - - path: / - allowEmptyInputs: true - keywordsfilters: - # most ES/ITA TV torrents are in XXxYY format, so we search without S/E prefixes and filter later - - name: re_replace - args: ["(?i)S0?(\\d{1,2})", " $1 "] - - name: re_replace - args: ["(?i)E(\\d{2,3})", " $1 "] - inputs: - s: "{{ .Keywords }}" - x: 0 - y: 0 - - rows: - selector: "#principal .miniboxs-ficha li:has(span:nth-of-type(2))" - filters: - - name: andmatch - - fields: - category_movie: - selector: .meta a - attribute: href - filters: - - name: regexp - args: "^.*/(peliculas)/.*$" - category_tv: - selector: .meta a - attribute: href - filters: - - name: regexp - args: "^.*/(series)/.*$" - category: - text: "{{ if or .Result.category_movie .Result.category_tv }}{{ or .Result.category_movie .Result.category_tv }}{{ else }}other{{ end }}" - _quality: - selector: span:nth-of-type(2) > i - filters: - - name: replace - args: ["---", ""] - _language: - selector: span#idiomacio > i > img - attribute: title - filters: - - name: replace - args: ["Pelicula en ", ""] - - name: replace - args: ["Español Latino", "LATiN SPANiSH"] - - name: replace - args: ["Español Castellano", "SPANiSH"] - - name: replace - args: ["Ingles", "English"] - - name: replace - args: ["Subtitulado", " SPASUBS"] - - name: replace - args: ["VOSE", ""] - title: - selector: div.imagen > a - attribute: title - # normalize to SXXEYY format - filters: - - name: replace - args: [" – ", " "] - - name: replace - args: ["(", ""] - - name: replace - args: [")", ""] - - name: re_replace - args: ["(\\d{2})×(\\d{2})", "S$1E$2"] - - name: re_replace - args: ["(\\d{1})×(\\d{2})", "S0$1E$2"] - - name: append - args: " {{ .Result._quality }} {{ .Result._language }}" - details: - selector: .meta a - attribute: href - download: - selector: .meta a - attribute: href - poster: - selector: .imagen img - attribute: data-src - size_optional: - selector: .voto1 - optional: true - filters: - - name: replace - args: ["desc.", "512 MB"] - - name: replace - args: ["s", ""] - size: - text: "{{ if .Result.size_optional }}{{ .Result.size_optional }}{{ else }}512 MB{{ end }}" - date: - text: now - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/empornium.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/empornium.yml deleted file mode 100644 index 300507fa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/empornium.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: empornium -name: Empornium -description: "Empornium (EMP) is a Private Torrent Tracker for 3X" -language: en-US -type: private -encoding: UTF-8 -# status: http://about.empornium.ph/ -links: - - https://www.empornium.sx/ # site forces www. -legacylinks: - - https://www.empornium.me/ - - https://empornium.sx/ - - https://www.empornium.is/ - -caps: - categorymappings: - - {id: 1, cat: XXX, desc: "Amateur"} - - {id: 2, cat: XXX, desc: "Anal"} - - {id: 5, cat: XXX, desc: "Asian"} - - {id: 6, cat: XXX, desc: "BBW"} - - {id: 30, cat: XXX, desc: "BDSM"} - - {id: 36, cat: XXX, desc: "Big Ass"} - - {id: 8, cat: XXX, desc: "Big Tits"} - - {id: 7, cat: XXX, desc: "Black"} - - {id: 9, cat: XXX, desc: "Classic"} - - {id: 37, cat: XXX, desc: "Creampie"} - - {id: 10, cat: XXX, desc: "Cumshot"} - - {id: 11, cat: XXX, desc: "DVD-R"} - - {id: 12, cat: XXX, desc: "Fetish"} - - {id: 14, cat: XXX, desc: "Gang Bang / Orgy"} - - {id: 39, cat: XXX, desc: "Gay / Bi"} - - {id: 56, cat: XXX, desc: "Hairy"} - - {id: 35, cat: XXX, desc: "Hardcore"} - - {id: 44, cat: XXX, desc: "HD Porn"} - - {id: 3, cat: XXX, desc: "Hentai / 3D"} - - {id: 25, cat: XXX, desc: "Homemade"} - - {id: 43, cat: XXX, desc: "Interracial"} - - {id: 16, cat: XXX, desc: "Latina"} - - {id: 23, cat: XXX, desc: "Lesbian"} - - {id: 52, cat: XXX, desc: "Lingerie"} - - {id: 27, cat: XXX, desc: "Magazines"} - - {id: 53, cat: XXX, desc: "Manga / Comic"} - - {id: 18, cat: XXX, desc: "Masturbation"} - - {id: 26, cat: XXX, desc: "Mature"} - - {id: 40, cat: XXX, desc: "Megapack"} - - {id: 41, cat: XXX, desc: "Natural Tits"} - - {id: 17, cat: XXX, desc: "Oral"} - - {id: 29, cat: XXX, desc: "Other"} - - {id: 47, cat: XXX, desc: "Parody"} - - {id: 24, cat: XXX, desc: "Paysite"} - - {id: 21, cat: XXX, desc: "Pictures / Images"} - - {id: 50, cat: XXX, desc: "Piss"} - - {id: 55, cat: XXX, desc: "Porn Music Videos"} - - {id: 46, cat: XXX, desc: "Pregnant / Preggo"} - - {id: 51, cat: XXX, desc: "Scat/Puke"} - - {id: 22, cat: XXX, desc: "Siterip"} - - {id: 20, cat: XXX, desc: "Softcore"} - - {id: 49, cat: XXX, desc: "Squirt"} - - {id: 34, cat: XXX, desc: "Straight"} - - {id: 19, cat: XXX, desc: "Teen"} - - {id: 15, cat: XXX, desc: "Transsexual"} - - {id: 45, cat: XXX, desc: "Voyeur"} - - {id: 13, cat: XXX, desc: "XXX Games / Apps"} - - modes: - search: [q] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the Empornium2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - path: login - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cinfo: "2550|1350|24|-60" - keeploggedin: 1 - error: - - selector: form#loginform > span.warning - test: - path: torrents.php - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "" - title: "{{ .Keywords }}" - order_by: time - order_way: desc - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - filelist: "" - taglist: "" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - download: - selector: a[href^="/torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - title: - selector: a[href^="/torrents.php?id="] - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "(\\d+)]=1" - details: - selector: a[href^="/torrents.php?id="] - attribute: href - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/empornium2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/empornium2fa.yml deleted file mode 100644 index b22f5d79..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/empornium2fa.yml +++ /dev/null @@ -1,165 +0,0 @@ ---- -id: empornium2fa -name: Empornium2FA -description: "Empornium (EMP) is a Private Torrent Tracker for 3X. This indexer uses cookie login for 2FA." -language: en-US -type: private -encoding: UTF-8 -# status: http://about.empornium.ph/ -links: - - https://www.empornium.sx/ # site forces www. -legacylinks: - - https://www.empornium.me/ - - https://empornium.sx/ - - https://www.empornium.is/ - -caps: - categorymappings: - - {id: 1, cat: XXX, desc: "Amateur"} - - {id: 2, cat: XXX, desc: "Anal"} - - {id: 5, cat: XXX, desc: "Asian"} - - {id: 6, cat: XXX, desc: "BBW"} - - {id: 30, cat: XXX, desc: "BDSM"} - - {id: 36, cat: XXX, desc: "Big Ass"} - - {id: 8, cat: XXX, desc: "Big Tits"} - - {id: 7, cat: XXX, desc: "Black"} - - {id: 9, cat: XXX, desc: "Classic"} - - {id: 37, cat: XXX, desc: "Creampie"} - - {id: 10, cat: XXX, desc: "Cumshot"} - - {id: 11, cat: XXX, desc: "DVD-R"} - - {id: 12, cat: XXX, desc: "Fetish"} - - {id: 14, cat: XXX, desc: "Gang Bang / Orgy"} - - {id: 39, cat: XXX, desc: "Gay / Bi"} - - {id: 56, cat: XXX, desc: "Hairy"} - - {id: 35, cat: XXX, desc: "Hardcore"} - - {id: 44, cat: XXX, desc: "HD Porn"} - - {id: 3, cat: XXX, desc: "Hentai / 3D"} - - {id: 25, cat: XXX, desc: "Homemade"} - - {id: 43, cat: XXX, desc: "Interracial"} - - {id: 16, cat: XXX, desc: "Latina"} - - {id: 23, cat: XXX, desc: "Lesbian"} - - {id: 52, cat: XXX, desc: "Lingerie"} - - {id: 27, cat: XXX, desc: "Magazines"} - - {id: 53, cat: XXX, desc: "Manga / Comic"} - - {id: 18, cat: XXX, desc: "Masturbation"} - - {id: 26, cat: XXX, desc: "Mature"} - - {id: 40, cat: XXX, desc: "Megapack"} - - {id: 41, cat: XXX, desc: "Natural Tits"} - - {id: 17, cat: XXX, desc: "Oral"} - - {id: 29, cat: XXX, desc: "Other"} - - {id: 47, cat: XXX, desc: "Parody"} - - {id: 24, cat: XXX, desc: "Paysite"} - - {id: 21, cat: XXX, desc: "Pictures / Images"} - - {id: 50, cat: XXX, desc: "Piss"} - - {id: 55, cat: XXX, desc: "Porn Music Videos"} - - {id: 46, cat: XXX, desc: "Pregnant / Preggo"} - - {id: 51, cat: XXX, desc: "Scat/Puke"} - - {id: 22, cat: XXX, desc: "Siterip"} - - {id: 20, cat: XXX, desc: "Softcore"} - - {id: 49, cat: XXX, desc: "Squirt"} - - {id: 34, cat: XXX, desc: "Straight"} - - {id: 19, cat: XXX, desc: "Teen"} - - {id: 15, cat: XXX, desc: "Transsexual"} - - {id: 45, cat: XXX, desc: "Voyeur"} - - {id: 13, cat: XXX, desc: "XXX Games / Apps"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: torrents.php - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "" - title: "{{ .Keywords }}" - order_by: time - order_way: desc - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - filelist: "" - taglist: "" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - download: - selector: a[href^="/torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - title: - selector: a[href^="/torrents.php?id="] - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "(\\d+)]=1" - details: - selector: a[href^="/torrents.php?id="] - attribute: href - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/emuwarez.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/emuwarez.yml deleted file mode 100644 index 41fb599b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/emuwarez.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: emuwarez -name: eMuwarez (API) -description: "eMuwarez is a SPANISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: es-ES -type: private -encoding: UTF-8 -links: - - https://emuwarez.com/ -legacylinks: - - https://emuwarez.it/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Películas"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 4, cat: TV/Documentary, desc: "Documentales"} - - {id: 5, cat: Audio, desc: "Musica"} - - {id: 6, cat: Console, desc: "Juegos"} - - {id: 7, cat: PC, desc: "Software"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your eMuwarez account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }} Spanish" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Ciencia ficción)", "Ciencia_ficción"] - - name: re_replace - args: ["(?i)(Película de TV)", "Película_de_TV"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 2.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/estone.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/estone.yml deleted file mode 100644 index 6bf56f8f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/estone.yml +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: estone -name: eStone -description: "eStone (BigTorrent) is a HUNGARIAN Private Torrent Tracker for 0DAY / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://estone.cc/ -legacylinks: - - http://estone.cc/ - -caps: - categorymappings: - - {id: 24, cat: Movies/SD, desc: "Film/XviD/Hun"} - - {id: 38, cat: Movies/SD, desc: "Film/XviD/Eng"} - - {id: 51, cat: Movies/SD, desc: "Film/SD/Hun"} - - {id: 52, cat: Movies/SD, desc: "Film/SD/Eng"} - - {id: 25, cat: Movies/DVD, desc: "Film/DVD/Hun"} - - {id: 26, cat: Movies/DVD, desc: "Film/DVD/Eng"} - - {id: 42, cat: Movies/HD, desc: "Film/HD/Hun"} - - {id: 50, cat: Movies/HD, desc: "Film/HD/Eng"} - - {id: 36, cat: TV, desc: "Sorozat/Hun"} - - {id: 47, cat: TV, desc: "Sorozat/Eng"} - - {id: 41, cat: Audio/MP3, desc: "Mp3/Hun"} - - {id: 40, cat: Audio/MP3, desc: "Mp3/Eng"} - - {id: 35, cat: PC/0day, desc: "Program"} - - {id: 28, cat: PC/Games, desc: "Játék/ISO"} - - {id: 30, cat: PC/Games, desc: "Játék/Rip"} - - {id: 32, cat: Console, desc: "Konzol"} - - {id: 34, cat: PC/Mobile-Other, desc: "Mobil"} - - {id: 44, cat: Books, desc: "Könyv/Hun"} - - {id: 33, cat: Books, desc: "Könyv/Eng"} - - {id: 31, cat: Other, desc: "Képek"} - - {id: 39, cat: XXX, desc: "XXX/Film"} - - {id: 49, cat: XXX/ImageSet, desc: "XXX/Kép"} - - modes: - search: [q] - tv-search: [q, season, ep, genre] - movie-search: [q, genre] - music-search: [q, genre] - book-search: [q, genre] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: feltoltve - options: - feltoltve: created - feltoltok: seeders - meret: size - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Anyone who does not enter the site for 180 days will be automatically deleted!" - -login: - path: login.php - method: post - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - error: - - selector: script:contains("hiba(\"") - message: - selector: script:contains("hiba(\"") - filters: - - name: replace - args: ["hiba(\"", ""] - - name: replace - args: ["\");", ""] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # http://estone.cc/letoltes.php?kereses_nev=wide&miben=0&cimke=&cat=0&submit.x=40&submit.y=9 - - path: letoltes.php - inputs: - $raw: "{{ range .Categories }}kat[]={{.}}&{{end}}{{ .Config.sort }}={{ .Config.type }}" - kereses_nev: "{{ .Keywords }}" - # 0 name, 1 performer, 2 director - miben: 0 - # genre - cimke: "{{ .Query.Genre }}" - # does not support imdbid search and does not return imdb link in results - - rows: - selector: "body > div[id=\"felsohatter2\"] > div[id^=\"torrent_\"]{{ if .Config.freeleech }}:has(img[src=\"pic/free.png\"]){{ else }}{{ end }}" - - fields: - title: - selector: a[title] - attribute: title - category: - selector: div#kategoria_torrent > a - attribute: href - filters: - - name: querystring - args: cat - details: - attribute: id - filters: - - name: replace - args: ["torrent_", "adatlap.php?id="] - download: - attribute: id - filters: - - name: replace - args: ["torrent_", "download.php?id="] - poster: - selector: img[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: borito\("(.*?) +?", - size: - selector: input[id^="meret_"] - attribute: value - grabs: - selector: div:nth-child(8) > div - seeders: - selector: div:nth-child(6) > div > a - leechers: - selector: div:nth-child(7) > div > a - date: - selector: div:nth-child(4) - filters: - - name: split - args: ["(", 0] - - name: append - args: "+01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/free.png"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="pic/dupla_up.png"]: 2 - "*": 1 - genre: - selector: div i - filters: - - name: regexp - args: "\\((.+?)\\)" - description: - case: - img[src="pic/yes.png"]: "Verified: {{ .Result.genre }}" - img[src="pic/nincs.png"]: "Unverified: {{ .Result.genre }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ex-torrenty.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ex-torrenty.yml deleted file mode 100644 index bb765dfb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ex-torrenty.yml +++ /dev/null @@ -1,163 +0,0 @@ ---- -id: ex-torrenty -name: Ex-torrenty -description: "Ex-torrenty is a POLISH Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: semi-private -encoding: UTF-8 -links: - - https://ex-torrenty.org/ - -caps: - # find the category numbers from the szukaj.php page - # and dont forget to update the search fields category case block - categorymappings: - - {id: 774, cat: Movies, desc: "Filmy Polskie"} - - {id: 642, cat: Movies, desc: "Filmy/Bajki"} - - {id: 1, cat: Movies/SD, desc: "Filmy XviD/DivX"} - - {id: 20, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 23, cat: Movies/HD, desc: "Filmy x264"} - - {id: 573, cat: Movies/UHD, desc: "Filmy 4K UHD"} - - {id: 17, cat: Movies/HD, desc: "Filmy HD"} - - {id: 265, cat: Movies/HD, desc: "Filmy x265 HEVC"} - - {id: 12, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 826, cat: Movies/HD, desc: "REMUX / Complete"} - - {id: 530, cat: Movies/SD, desc: "Filmy TS/CAM"} - - {id: 9, cat: PC, desc: "Programy"} - - {id: 7, cat: Console, desc: "Konsole"} - - {id: 6, cat: PC/Games, desc: "Gry"} - - {id: 5, cat: Audio, desc: "Muzyka"} - - {id: 305, cat: Audio/Video, desc: "Teledyski"} - - {id: 829, cat: TV, desc: "Seriale Polskie"} - - {id: 922, cat: TV/Foreign, desc: "Seriale Zagraniczne"} - - {id: 228, cat: XXX, desc: "Erotyka"} - - {id: 25, cat: Books/EBook, desc: "Książki"} - - {id: 739, cat: Audio/Audiobook, desc: "AudioBooki"} - - {id: 26, cat: Books/Comics, desc: "Komiksy"} - - {id: 27, cat: PC/Mac, desc: "Linux/Mac"} - - {id: 28, cat: TV/Sport, desc: "Sport"} - - {id: 29, cat: TV/Anime, desc: "Anime"} - - {id: 31, cat: PC/Mobile-Other, desc: "GSM/PDA"} - - {id: 32, cat: Other, desc: "Inne"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info - label: How to get the User-Agent - default: "
  1. From the same place you fetched the cookie,
  2. Find 'user-agent:' in the Request Headers section
  3. Select and Copy the whole user-agent string (everything after 'user-agent: ') and Paste here.
" - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="/logout.php"] - -search: - paths: - # https://ex-torrenty.org/szukaj.php?form-display-time=22&search=%25&cat=0 - - path: szukaj.php - inputs: - # does not support multi category selection. so using default for all - cat: 0 - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}%{{ end }}" - # does not support imdbid searching, or have imdb in results - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - rows: - selector: table[width="100%"] > tbody > tr:has(a[href*="torrent/"]) - - fields: - category: - selector: td:nth-child(1) - case: - ":contains(\"Polskie\")": 774 - ":contains(\"Filmy/Bajki\")": 642 - ":contains(\"XviD/DivX\")": 1 - ":contains(\"DVD\")": 20 - ":contains(\"x264\")": 23 - ":contains(\"4K UHD\")": 573 - ":contains(\"HD\")": 17 - ":contains(\"x265 HEVC\")": 265 - ":contains(\"3D\")": 12 - ":contains(\"TS/CAM\")": 530 - ":contains(\"REMUX\")": 826 - ":contains(\"Programy\")": 9 - ":contains(\"Konsole\")": 7 - ":contains(\"Gry\")": 6 - ":contains(\"Muzyka\")": 5 - ":contains(\"Teledyski\")": 305 - ":contains(\"Seriale Polskie\")": 829 - ":contains(\"Seriale Zagraniczne\")": 922 - ":contains(\"Erotyka\")": 228 - ":contains(\"Książki\")": 25 - ":contains(\"AudioBooki\")": 739 - ":contains(\"Komiksy\")": 26 - ":contains(\"Linux/Mac\")": 27 - ":contains(\"Sport\")": 28 - ":contains(\"Anime\")": 29 - ":contains(\"GSM/PDA\")": 31 - ":contains(\"Inne\")": 32 - title_phase1: - selector: a[href*="torrent/"] - title_multilang: - selector: a[href*="torrent/"] - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: a[href*="torrent/"] - attribute: href - download: - selector: a[href*="torrent/"] - attribute: href - filters: - - name: replace - args: ["torrent/", "download/"] - date: - text: now - size: - selector: td:nth-child(3) - seeders: - selector: td:nth-child(4) - leechers: - selector: td:nth-child(5) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/exitorrent-org.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/exitorrent-org.yml deleted file mode 100644 index bf132ac2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/exitorrent-org.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: exitorrent-org -name: exitorrent.org -description: "exitorrent.org is a POLISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: private -encoding: UTF-8 -links: - - https://exitorrent.org/ - -caps: - categorymappings: - - {id: 1, cat: PC, desc: "Aplikacje PC"} - - {id: 43, cat: Audio/Audiobook, desc: "Audiobooki"} - - {id: 33, cat: Movies/BluRay, desc: "BluRay"} - - {id: 28, cat: Movies/Other, desc: "Dla dzieci"} - - {id: 20, cat: Movies/DVD, desc: "DVD-R 5/9"} - - {id: 37, cat: Movies/SD, desc: "DVDFan"} - - {id: 31, cat: Books/EBook, desc: "eBooki / PDF"} - - {id: 30, cat: Movies/3D, desc: "Filmy/3D "} - - {id: 38, cat: Movies/SD, desc: "Filmy/BDRip-BRRip"} - - {id: 35, cat: Movies/HD, desc: "Filmy/x264-BluRay/HD"} - - {id: 45, cat: Movies/HD, desc: "Filmy/x265-BluRay/HD"} - - {id: 4, cat: PC/Games, desc: "Gry PC"} - - {id: 21, cat: PC/Mobile-Other, desc: "GSM/PDA"} - - {id: 2, cat: TV/HD, desc: "HDTV"} - - {id: 19, cat: Movies/SD, desc: "HQ / DVDRip"} - - {id: 6, cat: Audio, desc: "Muzyka"} - - {id: 39, cat: Movies/HD, desc: "Remux"} - - {id: 29, cat: Other, desc: "Różne"} - - {id: 7, cat: TV, desc: "Seriale"} - - {id: 22, cat: Audio/Video, desc: "Teledyski / Koncerty"} - - {id: 44, cat: Movies/UHD, desc: "UHD BluRay"} - - {id: 42, cat: Movies/UHD, desc: "UHD Remux"} - - {id: 23, cat: Movies/WEB-DL, desc: "WEB-DL / WEBRip"} - - {id: 9, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: drop_polish_prefix - type: checkbox - label: Drop the Polish title prefix - default: true - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href$="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 title, 1 descr, 2 both - blah: "{{ if .Query.IMDBID }}1{{ else }}0{{ end }}" - podkat: 0 - hashTag: 0 - # 0 all, 1 active, 2 dead - incldead: 0 - # 0 all, 1 polish, 5 dubbed, 6 subs, 2 non-polish, 3 freeleech, 4 highlighted - polish: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - keywordsfilters: - # remove the year from the titles as the site chokes on them during search #7424 - - name: re_replace - args: [" +(?:19|20)\\d{2} *$", ""] - - rows: - selector: table[class="text"][border="0"][cellspacing="0"][cellpadding="4"] > tbody > tr:has(a[href^="details.php?id="]):has(td[class*="tableBody1"], td[class*="tableBody1B"]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_raw: - selector: div[class="span_title"] > a[href^="details.php?id="] - title_stripped: - selector: div[class="span_title"] > a[href^="details.php?id="] - filters: - - name: re_replace - args: ["^(.*/)(.*)$", "$2"] - - name: trim - title: - text: "{{ if .Config.drop_polish_prefix }}{{ .Result.title_stripped }}{{ else }}{{ .Result.title_raw }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: div.span_icon > a[onclick^="return downloadTorrent"] - attribute: onclick - filters: - - name: re_replace - args: ["^return downloadTorrent\\('([^']+)', '([^']+)', '[^']*'\\);$", "download.php/$1/$2.torrent"] - genre: - selector: div.span_title span div - _language: - optional: true - selector: span.span_style_pl - attribute: title - _subbed: - optional: true - selector: span.span_style_plsub - attribute: title - description: - text: "{{ if .Result._language }}Language: {{ .Result._language }} {{ else }}{{ end }}{{ if .Result._subbed }}Subbed: {{ .Result._subbed }} {{ else }}{{ end }}{{ if .Result.genre }}Genres: {{ .Result.genre }}{{ else }}{{ end }}" - date: - selector: td:nth-child(5) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - a[href^="downloadfl.php/"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TBSource v1.5.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/exkinoray.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/exkinoray.yml deleted file mode 100644 index 203de3b6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/exkinoray.yml +++ /dev/null @@ -1,221 +0,0 @@ ---- -id: exkinoray -name: ExKinoRay -description: "ExKinoRay is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://exkinoray.ru/ -legacylinks: - - http://exkinoray.tv/ - - http://exkinoray.fun/ # site does not support https - -caps: - categorymappings: - - {id: 92, cat: TV, desc: "Кино Сериал"} - - {id: 46, cat: Movies, desc: "Кино Комедия"} - - {id: 47, cat: Movies, desc: "Кино Боевик"} - - {id: 48, cat: Movies, desc: "Кино Военный"} - - {id: 49, cat: Movies, desc: "Кино Вестерн"} - - {id: 50, cat: Movies, desc: "Кино Триллер"} - - {id: 51, cat: Movies, desc: "Кино Детектив"} - - {id: 52, cat: Movies, desc: "Кино Драма"} - - {id: 53, cat: Movies, desc: "Кино Мелодрама"} - - {id: 54, cat: Movies, desc: "Кино Индийское"} - - {id: 55, cat: Movies, desc: "Кино Азиатское"} - - {id: 59, cat: Movies, desc: "Кино Фантастика"} - - {id: 60, cat: Movies, desc: "Кино Фэнтези"} - - {id: 61, cat: Movies, desc: "Кино Ужас"} - - {id: 62, cat: Movies, desc: "Кино Мистика"} - - {id: 63, cat: Movies, desc: "Кино Приключения"} - - {id: 64, cat: Movies, desc: "Кино Наше кино"} - - {id: 65, cat: Movies, desc: "Кино Исторический"} - - {id: 66, cat: TV/Documentary, desc: "Кино Документальный"} - - {id: 67, cat: TV/Sport, desc: "Кино Спорт"} - - {id: 68, cat: Movies, desc: "Кино Семейный"} - - {id: 69, cat: Movies, desc: "Кино Детский"} - - {id: 70, cat: Movies, desc: "Кино Классика"} - - {id: 71, cat: Movies, desc: "Кино Концерт"} - - {id: 72, cat: TV, desc: "Кино ТВ-шоу"} - - {id: 73, cat: Movies, desc: "Кино Театр, опера"} - - {id: 74, cat: Movies, desc: "Кино Мюзикл"} - - {id: 75, cat: Movies, desc: "Кино Эротика"} - - {id: 77, cat: TV, desc: "Мульт Буржуйский"} - - {id: 78, cat: TV, desc: "Мульт Русский"} - - {id: 79, cat: TV/Anime, desc: "Мульт Аниме"} - - {id: 80, cat: Audio, desc: "Музыка Буржуйская"} - - {id: 81, cat: Audio, desc: "Музыка Русская"} - - {id: 82, cat: Audio, desc: "Музыка Сборники"} - - {id: 83, cat: Audio/Audiobook, desc: "Другое Аудио книги"} - - {id: 84, cat: Audio/Video, desc: "Другое Видеоклипы"} - - {id: 85, cat: PC/Games, desc: "Другое Игры"} - - {id: 86, cat: PC/0day, desc: "Другое Программы"} - - {id: 87, cat: Console/XBox, desc: "Другое XBox"} - - {id: 88, cat: Console/PSP, desc: "Другое PSP"} - - {id: 89, cat: Books/Mags, desc: "Другое Журналы"} - - {id: 90, cat: Other, desc: "Другое Графика"} - - {id: 93, cat: Other, desc: "Другое Библиотека"} - - {id: 94, cat: Console/PS3, desc: "Другое PS3"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - autologin: 1 - returnto: index.php - error: - - selector: td.embedded:contains("Ошибка входа") - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # http://exkinoray.tv/browse.php?search=&incldead=1&cat=0 - - path: browse.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}c{{.}}=1&{{end}}{{ else }}cat=0{{ end }}" - # 0 active, 1 incldead, 2 onlydead, 4 noseeds (how is this different from onlydead?) - incldead: 1 - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: table.begins > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date: - selector: td:nth-child(2) i - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - files: - selector: td:nth-child(3) - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="details.php?id="] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/explosiv-world.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/explosiv-world.yml deleted file mode 100644 index cd1ec7d4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/explosiv-world.yml +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: explosiv-world -replaces: - - torrent-explosiv -name: Explosiv-World -description: "Explosiv-World (E-W) is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://explosiv-world.biz/ -legacylinks: - - https://torrent-explosiv.eu/ - - https://torrent-explosiv.hopto.org/ - - https://torrent-explosiv.me/ - -caps: - categorymappings: - - {id: 114, cat: Movies/UHD, desc: "Filme > UHD"} - - {id: 32, cat: Movies/HD, desc: "Filme > HD"} - - {id: 28, cat: Movies/SD, desc: "Filme > SD"} - - {id: 145, cat: Movies/UHD, desc: "Filme > Pack UHD"} - - {id: 138, cat: Movies/HD, desc: "Filme > Pack HD"} - - {id: 140, cat: Movies/SD, desc: "Filme > Pack SD"} - - {id: 143, cat: Movies/Other, desc: "Filme > Anime"} - - {id: 139, cat: TV/UHD, desc: "Serien > UHD"} - - {id: 57, cat: TV/HD, desc: "Serien > HD"} - - {id: 60, cat: TV/SD, desc: "Serien > SD"} - - {id: 144, cat: TV/Anime, desc: "Serien > Anime"} - - {id: 146, cat: TV/UHD, desc: "Serien > Pack UHD/HD"} - - {id: 59, cat: TV/SD, desc: "Serien > Pack SD"} - - {id: 110, cat: Audio, desc: "Audio > Alben"} - - {id: 148, cat: Audio, desc: "Audio > Mixe"} - - {id: 149, cat: Audio, desc: "Audio > Sampler/OST"} - - {id: 150, cat: Audio, desc: "Audio > Charts/Singles"} - - {id: 48, cat: Audio/Lossless, desc: "Audio > Flac"} - - {id: 52, cat: Audio/MP3, desc: "Audio > Pack mp3"} - - {id: 147, cat: Audio/Lossless, desc: "Audio > Pack Flac"} - - {id: 63, cat: Audio/Audiobook, desc: "Audio > Hörbuch"} - - {id: 120, cat: Audio/Video, desc: "Audio > Videos"} - - {id: 17, cat: PC/Games, desc: "Spiele > Windows"} - - {id: 24, cat: Console, desc: "Spiele > Konsole"} - - {id: 126, cat: Console/Other, desc: "Spiele > Mobil"} - - {id: 121, cat: Console, desc: "Spiele > andere"} - - {id: 12, cat: PC/0day, desc: "Software > Windows"} - - {id: 11, cat: PC/Mobile-Other, desc: "Software > Mobil"} - - {id: 141, cat: PC/Mobile-Other, desc: "Software > andere"} - - {id: 61, cat: TV/Sport, desc: "Sport > UHD/HD"} - - {id: 62, cat: TV/Sport, desc: "Sport > SD"} - - {id: 14, cat: TV/Documentary, desc: "Doku > UHD/HD"} - - {id: 15, cat: TV/Documentary, desc: "Doku > SD"} - - {id: 123, cat: TV/Documentary, desc: "Doku > Pack UHD/HD"} - - {id: 151, cat: TV/Documentary, desc: "Doku > Pack SD"} - - {id: 96, cat: Books/Other, desc: "ePaper > Zeitungen"} - - {id: 136, cat: Books/EBook, desc: "ePaper > E-Book"} - - {id: 137, cat: Books/Mags, desc: "ePaper > Magazine"} - - {id: 73, cat: XXX/x264, desc: "XXX > HD"} - - {id: 76, cat: XXX/SD, desc: "XXX > SD"} - - {id: 75, cat: XXX/Pack, desc: "XXX > Pack"} - - {id: 142, cat: XXX/ImageSet, desc: "XXX > andere"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_password - type: info - label: About Password Expiry - default: "E-W has an 8 week Password Expiry regime. So if the Indexer has stopped working then the first thing you should do is check if your password has expired by login in at Explosiv-World, and if necessary use the Passwort anfordern (Request password) button to renew it." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: onlyupload - type: checkbox - label: Search OnlyUpload - default: false - - name: info_free - type: info - label: About OnlyUpload at E-W - default: "OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech)." - -login: - path: login.php - method: form - form: form - inputs: - login_identifier: "{{ .Config.username }}" - password: "{{ .Config.password }}" - loggiin: Einloggen - error: - - selector: div.alert - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://explosiv-world.biz/torrents_search.php?ajax=true&search=&page=1 - - path: torrents_search.php - response: - type: json - - inputs: - ajax: true - search: "{{ .Keywords }}" - page: 1 - # all=active+inactive, yes=active, no=inactive (the default is all, which ajax simply drops the parm) - # visible: all - # all=all+onlyup, yes=onlyup, no=without onlyup (the default is all, which ajax simply drops the parm) - free: "{{ if .Config.onlyupload }}yes{{ else }}{{ end }}" - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - - rows: - selector: $ - - fields: - categorydesc: - selector: category - filters: - - name: htmldecode - title: - selector: name - _id: - selector: id - details: - text: "details.php?id={{ .Result._id }}" - download: - selector: download_link - poster: - selector: cover - size: - selector: size - date: - # 25.12.2024 16:02 - selector: added - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm zzz" - seeders: - selector: seeders - leechers: - selector: leechers - _onlyup: - selector: free_button:contains(Only Upload) - optional: true - downloadvolumefactor: - text: "{{ if .Result._onlyup }}0{{ else }}1{{ end }}" - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# THX JSON (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/extratorrent-st.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/extratorrent-st.yml deleted file mode 100644 index d7eef687..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/extratorrent-st.yml +++ /dev/null @@ -1,144 +0,0 @@ ---- -id: extratorrent-st -name: ExtraTorrent.st -description: "ExtraTorrent.st is a Public tracker for MOVIE / TV / GENERAL magnets" -language: en-US -type: public -encoding: UTF-8 -links: - - https://extratorrent.st/ - - https://extratorrent.ninjaproxy1.com/ - - https://extratorrent.proxyninja.org/ - - https://extratorrent.proxyninja.net/ -legacylinks: - - https://extratorrent.mrunblock.bond/ - - https://extratorrent.nocensor.cloud/ - - https://extratorrent.unblockit.download/ # 502 - -caps: - categorymappings: - - {id: 3D Movies, cat: Movies/3D, desc: Movies 3D} - - {id: AAC, cat: Audio, desc: Music AAC} - - {id: "Adult / Porn", cat: XXX, desc: "Adult / Porn"} - - {id: Android, cat: PC/Mobile-Android, desc: Software Android} - - {id: Anime, cat: TV/Anime, desc: Anime} - - {id: Audio books, cat: Audio/Audiobook, desc: Books Audiobook} - - {id: Bollywood, cat: Movies, desc: Bollywood} - - {id: Comics, cat: Books/Comics, desc: Books Comics} - - {id: DVD, cat: Movies/DVD, desc: Movies DVD} - - {id: Documentary, cat: TV/Documentary, desc: Documentary} - - {id: Dubbed Movies, cat: Movies/Foreign, desc: Movies Dubbed} - - {id: Ebooks, cat: Books/EBook, desc: Books Ebook} - - {id: English-translated, cat: TV/Anime, desc: Anime English-translated} - - {id: Episodes HD, cat: TV/HD, desc: Episodes HD} - - {id: Episodes 4K UHD, cat: TV/UHD, desc: Episodes 4K UHD} - - {id: Games, cat: XXX/Other, desc: Adult Games} - - {id: Hentai, cat: XXX/Other, desc: Adult Hentai} - - {id: Highres Movies, cat: Movies/HD, desc: Movies HD} - - {id: Linux, cat: PC, desc: Software Linux} - - {id: "Live Action [Non-English]", cat: TV/Anime, desc: "Live Action [Non-English]"} - - {id: Lossless, cat: Audio/Lossless, desc: Music Lossless} - - {id: "Manga [English-translated]", cat: Books/Comics, desc: "Manga [English-translated]"} - - {id: "Manga [Raw]", cat: Books/Comics, desc: "Manga [Raw]"} - - {id: MP3, cat: Audio/MP3, desc: Music MP3} - - {id: MP4, cat: Movies, desc: Movies MP4} - - {id: Mac, cat: PC/Mac, desc: Software Mac} - - {id: Magazines, cat: XXX/Other, desc: Adult Magazines} - - {id: Movie clips, cat: Movies, desc: Movies clips} - - {id: Movies, cat: Movies, desc: Movies} - - {id: Music, cat: Audio, desc: Music} - - {id: Music videos, cat: Audio/Video, desc: Music Videos} - - {id: NDS, cat: Console/NDS, desc: Games NDS} - - {id: Other Applications, cat: PC, desc: Other Applications} - - {id: Other Games, cat: Console/Other, desc: Games Other} - - {id: Other Movies, cat: Movies/Other, desc: Movies Other} - - {id: Other Music, cat: Audio/Other, desc: Music Other} - - {id: Other, cat: Other/Misc, desc: Other} - - {id: PC Games, cat: PC/Games, desc: PC Games} - - {id: PS3, cat: Console/PS3, desc: Games PS3} - - {id: PS4, cat: Console/PS4, desc: Games PS4} - - {id: PSP, cat: Console/PSP, desc: Games PSP} - - {id: Pictures, cat: XXX/ImageSet, desc: Adult Pictures} - - {id: Radio Shows, cat: Audio/Other, desc: Music Radio} - - {id: Raw, cat: TV/Anime, desc: Anime Raw} - - {id: Season Packs, cat: TV, desc: Season Packs} - - {id: Software, cat: PC/0day, desc: Software} - - {id: Sports, cat: TV/Sport, desc: Sports} - - {id: Subs, cat: TV/Anime, desc: Anime Subs} - - {id: Switch, cat: Console/Other, desc: Games Switch} - - {id: TV, cat: TV, desc: TV} - - {id: UltraHD, cat: Movies/UHD, desc: Movies UHD} - - {id: Video, cat: XXX, desc: Adult / Porn} - - {id: Wii, cat: Console/Wii, desc: Games Wii} - - {id: Windows, cat: PC, desc: Software Windows} - - {id: Xbox360, cat: Console/XBox 360, desc: Games Xbox360} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -search: - paths: - # https://extratorrent.st/search/?srt=added&order=desc&search=captain&new=1&x=0&y=0 - - path: "{{ if .Keywords }}search/?srt=added&order=desc&search={{ .Keywords }}&new=1&x=0&y=0{{ else }}{{ end }}" - keywordsfilters: - - name: re_replace - args: ["[\\s]+", "."] - - rows: - selector: tr[class^="tl"]:has(a[href^="magnet:?xt="]) - filters: - - name: andmatch - - fields: - category: - selector: span.c_tor - filters: - - name: replace - args: ["in ", ""] - - name: trim - title: - selector: a[href^="/torrent/"]:not([href$="comments"]) - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="magnet:?xt="] - attribute: href - date: - # 8m , 13h, 2d , 3w , 1m , 1y # site uses m for both minutes and months!?!? - selector: td:nth-last-of-type(5) - filters: - - name: replace - args: ["m", " minutes"] - - name: replace - args: ["h", " hours"] - - name: replace - args: ["y", " years"] - - name: replace - args: ["d", " days"] - - name: replace - args: ["w", " weeks"] - - name: timeago - size: - selector: td:nth-last-of-type(4) - seeders: - selector: td.sy, td.sn - optional: true - default: 0 - leechers: - selector: td.ly, td.ln - optional: true - default: 0 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/extremebits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/extremebits.yml deleted file mode 100644 index e0f7d05c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/extremebits.yml +++ /dev/null @@ -1,168 +0,0 @@ ---- -id: extremebits -name: ExtremeBits -description: "ExtremeBits is a Private Torrent Tracker for EXTREME SPORTS" -language: en-US -type: private -encoding: UTF-8 -certificates: - - f31a18c5e097512324e47b20f67ab4607553ae3c # Expired 24th Feb 2026 -links: - - https://extremebits.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "BASE jump"} - - {id: 10, cat: Movies, desc: "BMX"} - - {id: 3, cat: Movies, desc: "Climbing"} - - {id: 19, cat: Other, desc: "Covers"} - - {id: 20, cat: Movies, desc: "Fiddy/MiniMX"} - - {id: 22, cat: Movies, desc: "Graffiti"} - - {id: 18, cat: Movies, desc: "Homevid"} - - {id: 16, cat: Movies, desc: "Inline Skate"} - - {id: 25, cat: Movies, desc: "Kayaking"} - - {id: 28, cat: Movies, desc: "Kiteboard"} - - {id: 17, cat: Movies, desc: "MC"} - - {id: 13, cat: Other, desc: "Misc"} - - {id: 4, cat: Movies, desc: "MotoX"} - - {id: 11, cat: Movies, desc: "MTB"} - - {id: 21, cat: Audio, desc: "Music"} - - {id: 26, cat: Movies, desc: "Paintball"} - - {id: 5, cat: Movies, desc: "Parkour"} - - {id: 24, cat: Movies, desc: "Quad/ATV"} - - {id: 29, cat: Movies, desc: "Rally/4x4"} - - {id: 6, cat: Movies, desc: "Skateboard"} - - {id: 12, cat: Movies, desc: "Ski"} - - {id: 14, cat: Movies, desc: "Skydive"} - - {id: 7, cat: Movies, desc: "Snowboard"} - - {id: 9, cat: Movies, desc: "Snowmobile"} - - {id: 15, cat: Movies, desc: "Streetrace"} - - {id: 2, cat: Movies, desc: "Surf"} - - {id: 23, cat: TV, desc: "Tv"} - - {id: 8, cat: Movies, desc: "Wakeboard/W.Ski"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If you don't log in, you will be deleted for inactivity after a few months." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - input[placeholder="Username"]: "{{ .Config.username }}" - input[placeholder="Password"]: "{{ .Config.password }}" - selectors: true - error: - - selector: h2:contains("fail") - message: - selector: div.contentframe - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech - incl: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 name, 1 descr, 2 both - searchin: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not support imdbid searching or have imdb links in results. - - rows: - selector: table.crowdedtablecenter > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php", "download.php"] - files: - selector: td:nth-of-type(5) - seeders: - selector: td:nth-of-type(7) - leechers: - selector: td:nth-of-type(8) - grabs: - selector: td:nth-of-type(10) a - size: - selector: td:nth-of-type(10) - remove: a - category_title: - selector: td:nth-of-type(1) a img - attribute: alt - description: - selector: td:nth-of-type(4) - filters: - - name: prepend - args: "cat={{ .Result.category_title }}, wait=" - downloadvolumefactor: - case: - img[src="img/freedownload.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - date: - selector: td:nth-of-type(2) - remove: a - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - minimumratio: - text: 0.6 -# TBDEV diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/eztv.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/eztv.yml deleted file mode 100644 index 620b98f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/eztv.yml +++ /dev/null @@ -1,99 +0,0 @@ ---- -id: eztv -name: EZTV -description: "EZTV is a Public torrent site for TV shows" -language: en-US -type: public -encoding: UTF-8 -links: - - https://eztvx.to/ - - https://eztv.wf/ - - https://eztv.tf/ - - https://eztv.yt/ - - https://eztv1.xyz/ -legacylinks: - - https://eztv.ag/ # redirects to .re - - https://eztv.it/ # redirects to .re - - https://eztv.ch/ # redirects to .re - - https://eztv.io/ - - https://eztv.re/ - - https://eztv.li/ - - https://eztv.mrunblock.bond/ - - https://eztv.nocensor.cloud/ - - https://eztv.unblockninja.com/ # layout=def_wlinks not working - - https://eztv.ninjaproxy1.com/ # layout=def_wlinks not working - - https://eztv.proxyninja.org/ # layout=def_wlinks not working - - https://eztv.abcproxy.org/ - - https://eztv.unblockit.download/ - -caps: - categories: - 1: TV - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: [] - -search: - paths: - - path: "{{ if .Keywords }}search/{{ .Keywords }}{{ else }}home{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["\\bS\\d{2,3}\\b", ""] # remove season tag without episode (search doesn't support it) - - name: trim - # fixes for site search issues - Prowlarr #1094 - - name: replace - args: ["-", ""] - - name: replace - args: [" ", "-"] - - name: replace - args: ["&", ""] - - headers: - cookie: ["sort_no=100; q_filter=all; q_filter_web=on; q_filter_reality=on; q_filter_x265=on; layout=def_wlinks"] # show 100 results for keywordless search and show links in results - - rows: - # only use latest added torrents table for keywordless search to avoid duplicates, some torrents don't have any download links so skip them - selector: "table.forum_header_border:contains('Latest') tr[name='hover'].forum_header_border:has(a.magnet), table.forum_header_border:contains('Releases') tr[name='hover'].forum_header_border:has(a.magnet)" - filters: - - name: andmatch - - fields: - category: - text: 1 - title: - selector: td:nth-child(2) a - attribute: title - filters: - - name: replace - args: ["[eztv]", ""] - - name: re_replace - args: ["\\(.*\\)$", ""] - - name: trim - details: - selector: td:nth-child(2) a - attribute: href - download: - selector: td:nth-child(3) a.magnet, td:nth-child(3) a - attribute: href - size: - selector: td:nth-child(4) - optional: true - default: 512 MB - date: - selector: td:nth-child(5) - filters: - - name: append - args: " ago" - seeders: - selector: td:nth-child(6) - leechers: - text: 0 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/eztvl.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/eztvl.yml deleted file mode 100644 index 64402524..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/eztvl.yml +++ /dev/null @@ -1,118 +0,0 @@ ---- -id: eztvl -name: EZTVL -description: "EZTV is a Public torrent site for TV shows. This indexer supports login." -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://eztvx.to/ - - https://eztv.wf/ - - https://eztv.tf/ - - https://eztv.yt/ - - https://eztv1.xyz/ -legacylinks: - - https://eztv.ag/ # redirects to .re - - https://eztv.it/ # redirects to .re - - https://eztv.ch/ # redirects to .re - - https://eztv.io/ - - https://eztv.re/ - - https://eztv.li/ - - https://eztv.mrunblock.bond/ - - https://eztv.nocensor.cloud/ - - https://eztv.unblockninja.com/ # layout=def_wlinks not working - - https://eztv.ninjaproxy1.com/ # layout=def_wlinks not working - - https://eztv.proxyninja.org/ # layout=def_wlinks not working - - https://eztv.abcproxy.org/ - - https://eztv.unblockit.download/ - -caps: - categories: - 1: TV - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: login/ - method: post - inputs: - loginname: "{{ .Config.username }}" - password: "{{ .Config.password }}" - submit: Login - error: - - selector: span.text-error - test: - path: home - selector: a[href="/logout/"] - -search: - paths: - - path: "{{ if .Keywords }}search/{{ .Keywords }}{{ else }}home{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["\\bS\\d{2,3}\\b", ""] # remove season tag without episode (search doesn't support it) - - name: trim - # fixes for site search issues - Prowlarr #1094 - - name: replace - args: ["-", ""] - - name: replace - args: [" ", "-"] - - name: replace - args: ["&", ""] - - headers: - cookie: ["sort_no=100; q_filter=all; q_filter_web=on; q_filter_reality=on; q_filter_x265=on; layout=def_wlinks"] # show 100 results for keywordless search and show links in results - - rows: - # only use latest added torrents table for keywordless search to avoid duplicates, some torrents don't have any download links so skip them - selector: "table.forum_header_border:contains('Latest') tr[name='hover'].forum_header_border:has(a.magnet), table.forum_header_border:contains('Releases') tr[name='hover'].forum_header_border:has(a.magnet)" - filters: - - name: andmatch - - fields: - category: - text: 1 - title: - selector: td:nth-child(2) a - attribute: title - filters: - - name: replace - args: ["[eztv]", ""] - - name: re_replace - args: ["\\(.*\\)$", ""] - - name: trim - details: - selector: td:nth-child(2) a - attribute: href - download: - selector: td:nth-child(3) a.magnet, td:nth-child(3) a - attribute: href - size: - selector: td:nth-child(4) - optional: true - default: 512 MB - date: - selector: td:nth-child(5) - filters: - - name: append - args: " ago" - seeders: - selector: td:nth-child(6) - leechers: - text: 0 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/f1carreras-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/f1carreras-api.yml deleted file mode 100644 index d1562b51..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/f1carreras-api.yml +++ /dev/null @@ -1,169 +0,0 @@ ---- -id: f1carreras-api -name: F1Carreras (API) -description: "F1Carreras is a SPANISH Private Torrent Tracker for Motor Racing TV Releases" -language: es-MX -type: private -encoding: UTF-8 -links: - - https://f1carreras.xyz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: TV/Sport, desc: "Formula 1"} - - {id: 4, cat: TV/Sport, desc: "IndyCar Series"} - - {id: 5, cat: TV/Sport, desc: "Formula 2"} - - {id: 6, cat: TV/Sport, desc: "Formula E"} - - {id: 7, cat: TV/Sport, desc: "MotoGP"} - - {id: 8, cat: TV/Sport, desc: "Nascar"} - - {id: 9, cat: TV/Sport, desc: "Formula 3"} - - {id: 10, cat: TV/Sport, desc: "DTM"} - - {id: 11, cat: TV/Sport, desc: "Rally"} - - {id: 12, cat: TV/Sport, desc: "WEC"} - - {id: 13, cat: TV/Sport, desc: "Other Series"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your F1Carreras account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # disabled due to not being properly mapped - # seasonNumber: "{{ .Query.Season }}" - # episodeNumber: "{{ .Query.Ep }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - name: re_replace # S2024 to 2024 and S2024E97 to 2024 97 - args: ["\\b(?:S(\\d{2,4}))(?:E(\\d{2,4}))?\\b", "$1 $2"] - - name: trim - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumseedtime: - # 2 day (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/f1gp.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/f1gp.yml deleted file mode 100644 index 7096c012..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/f1gp.yml +++ /dev/null @@ -1,160 +0,0 @@ ---- -id: f1gp -name: F1GP -description: "F1GP is a Private Torrent Tracker for F1, F2, F3, FE, F1 Academy Racing" -language: en-US -type: private -encoding: UTF-8 -links: - - https://f1gp.site/ - -caps: - categorymappings: - - {id: 11, cat: TV/Sport, desc: "F1 Races"} - - {id: 1, cat: TV/Sport, desc: "F1 Qualifying"} - - {id: 2, cat: TV/Sport, desc: "F1 Sprint Races"} - - {id: 3, cat: TV/Sport, desc: "F1 Free Practice"} - - {id: 5, cat: TV/Sport, desc: "F1 Pre Races"} - - {id: 6, cat: TV/Sport, desc: "F1 Warm Up"} - - {id: 4, cat: TV/Sport, desc: "F1 Documentary"} - - {id: 7, cat: TV/Sport, desc: "F1 Oldies"} - - {id: 8, cat: TV/Sport, desc: "F1 Press Conferences"} - - {id: 9, cat: TV/Sport, desc: "F1 Post Races"} - - {id: 14, cat: TV/Sport, desc: "F1 Academy"} - - {id: 15, cat: TV/Sport, desc: "F1 Movies"} - - {id: 16, cat: TV/Sport, desc: "F1 Series"} - - {id: 17, cat: TV/Sport, desc: "F2 Championship"} - - {id: 18, cat: TV/Sport, desc: "F3 Championship"} - - {id: 19, cat: TV/Sport, desc: "FE Championship"} - - {id: 20, cat: TV/Sport, desc: "Freeleech"} - - {id: 21, cat: TV/Sport, desc: "UFC"} - - {id: 22, cat: TV/Sport, desc: "MotoGP"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. Default is 20. - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: tr td span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://f1gp.site/index.php?page=torrents&search=&category=0&active=0 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ range .Categories }}{{.}};{{end}}" - # 0 filename, 1 file&descr, 2 descr, 3 uploaders, 5 gold, 6 silver, 7 bronze - options: "{{ if .Config.freeleech }}5{{ else }}0{{ end }}" - # 0 all, 1 active only, 2 dead only - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: table.lista > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details&id="] - details: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: onmouseover - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: onmouseover - filters: - - name: regexp - args: src=(.+?) width - - name: replace - args: ["torrentimg/nocover.jpg", ""] - size: - selector: td:nth-child(11) - date: - # auto adjusted by site account profile - selector: td:nth-child(6) - filters: - - name: dateparse - args: "dd/MM/yyyy" - seeders: - selector: td:nth-child(7) - optional: true - default: 0 - leechers: - optional: true - selector: td:nth-child(8) - default: 0 - grabs: - optional: true - selector: td:nth-child(9) - default: 0 - downloadvolumefactor: - case: - img[src="images/freeleech.gif"]: 0 - img[src="images/gold.gif"]: 0 - img[src="images/silver.gif"]: 0.5 - img[src="images/bronze.gif"]: 0.75 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# xbtitFM v4.1.32 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fanoin.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fanoin.yml deleted file mode 100644 index 74d3cbeb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fanoin.yml +++ /dev/null @@ -1,158 +0,0 @@ ---- -id: fanoin -name: FANO.IN -description: "Fano.in is a LATVIAN Private Torrent Tracker for 0DAY / GENERAL" -language: lv-LV -type: private -encoding: UTF-8 -links: - - https://www.fano.in/ - -caps: - categorymappings: - - {id: 20, cat: Movies/SD, desc: "Movies/SD"} - - {id: 6, cat: TV/SD, desc: "TV/SD"} - - {id: 7, cat: PC/Games, desc: "Games/PC ISO"} - - {id: 5, cat: Audio, desc: "Music"} - - {id: 47, cat: Movies, desc: "Packs/Movies"} - - {id: 44, cat: Audio/Audiobook, desc: "A-Books"} - - {id: 27, cat: TV/Anime, desc: "Anime"} - - {id: 17, cat: Movies/Foreign, desc: "Movies/Rus"} - - {id: 33, cat: TV/Foreign, desc: "TV/Rus"} - - {id: 12, cat: PC/Games, desc: "Games/PC Rips"} - - {id: 31, cat: Audio/Lossless, desc: "Music/HQ"} - - {id: 49, cat: TV, desc: "Packs/TV"} - - {id: 41, cat: Books, desc: "E-Books"} - - {id: 29, cat: Books, desc: "Cartoons"} - - {id: 24, cat: Movies/Foreign, desc: "Movies/Lat"} - - {id: 25, cat: TV/Foreign, desc: "TV/Lat"} - - {id: 34, cat: Console/XBox, desc: "Games/Xbox"} - - {id: 19, cat: Audio/Video, desc: "Music Videos"} - - {id: 46, cat: Console, desc: "Packs/Games"} - - {id: 42, cat: Other, desc: "Study"} - - {id: 52, cat: Movies/3D, desc: "3D"} - - {id: 37, cat: Movies/HD, desc: "Movies/HD"} - - {id: 35, cat: TV/HD, desc: "TV/HD"} - - {id: 43, cat: Console/PS4, desc: "Games/PS"} - - {id: 38, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 48, cat: Audio, desc: "Packs/Music"} - - {id: 36, cat: Other, desc: "X-mas"} - - {id: 53, cat: Movies/SD, desc: "Movies/CAM"} - - {id: 4, cat: Movies/DVD, desc: "Movies/DVD-R"} - - {id: 32, cat: TV, desc: "TV/Facts"} - - {id: 40, cat: Console/Wii, desc: "Games/Wii"} - - {id: 22, cat: PC/0day, desc: "Appz/misc"} - - {id: 50, cat: XXX/Pack, desc: "Packs/XXX"} - - {id: 9, cat: XXX, desc: "XXX"} - - {id: 45, cat: XXX, desc: "XXX/HD"} - - {id: 54, cat: Movies, desc: "Movies/Retro"} - - {id: 23, cat: TV/Sport, desc: "TV/Sport"} - - {id: 51, cat: Console, desc: "Games/Misc"} - - {id: 1, cat: PC/0day, desc: "Appz/PC ISO"} - - {id: 55, cat: Movies/UHD, desc: "Movies/4K"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("failed")) - test: - path: browse_old.php - selector: a[href="/logout.php"] - -search: - paths: - - path: browse_old.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid search - # 0 active 1 incldead 2 onlydead - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: tr.browse_actions - - fields: - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - category: - selector: a[href^="browse_old.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php", "download.php"] - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(6) - date: - selector: td:nth-child(2) > small:nth-last-child(2), td:nth-child(2) > small - filters: - - name: replace - args: ["Šodien", "Today"] - - name: replace - args: ["Vakar", "Yesterday"] - downloadvolumefactor: - case: - img[alt="Free"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[alt="x2"]: 2 - "*": 1 - description: - selector: td:nth-child(2) > small:nth-last-child(1) - remove: a[href^="details.php?id="] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fappaizuri.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fappaizuri.yml deleted file mode 100644 index ab4f7d66..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fappaizuri.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: fappaizuri -name: Fappaizuri -description: "Fappaizuri is a Private Torrent Tracker for HENTAI / JAV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.fappaizuri.me/ -legacylinks: - - https://fappaizuri.me/ - -caps: - categorymappings: - - {id: 5, cat: XXX, desc: "JAV"} - - {id: 6, cat: Books, desc: "Manga"} - - {id: 7, cat: Console, desc: "H-games"} - - {id: 8, cat: TV/Anime, desc: "Hentai"} - - {id: 9, cat: XXX, desc: "OFFS"} - - modes: - search: [q] - tv-search: [q, season, ep] - book-search: [q] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the Fappaizuri2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: lang - type: select - label: Language - default: 0 - options: - 0: All - 4: Japanese - 5: Chinese - 6: Malaysia - 7: North Korea - 8: English - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - method: post - path: account-login.php - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: b:contains("The specified username or password was incorrect.") - test: - path: index.php - selector: a[href="account-logout.php"] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - # https://fappaizuri.me/torrents-search.php?search=&c8=1&c6=1&incldead=1&freeleech=0&lang=0 - - path: torrents-search.php - - inputs: - $raw: "{{ range .Categories }}&c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 notfree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - lang: "{{ .Config.lang }}" - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - rows: - selector: tr.t-row - - fields: - category: - selector: a[href^="torrents-search.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="torrents-details.php?id="] > b - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="torrents-details.php?id="] - attribute: href - poster: - selector: a[href^="torrents-details.php?id="] - attribute: onMouseover - filters: - - name: regexp - args: src=(.+?)> - _language: - selector: img[src*="images/languages/"] - attribute: alt - optional: true - description: - selector: font[color="red"] - filters: - - name: append - args: "{{ if .Result._language }} - {{ .Result._language }}{{ else }}{{ end }}" - size: - selector: td:nth-child(3) - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - date: - selector: td:nth-child(8) - filters: - - name: timeago - downloadvolumefactor: - case: - img[src="images/free.png"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="images/doubleupload.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 4 days (as seconds = 4 x 24 x 60 x 60) - text: 345600 -# evolution diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fappaizuri2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fappaizuri2fa.yml deleted file mode 100644 index 52805f7b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fappaizuri2fa.yml +++ /dev/null @@ -1,154 +0,0 @@ ---- -id: fappaizuri2fa -name: Fappaizuri2FA -description: "Fappaizuri is a Private Torrent Tracker for HENTAI / JAV. This indexer uses cookie login for 2FA." -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.fappaizuri.me/ -legacylinks: - - https://fappaizuri.me/ - -caps: - categorymappings: - - {id: 5, cat: XXX, desc: "JAV"} - - {id: 6, cat: Books, desc: "Manga"} - - {id: 7, cat: Console, desc: "H-games"} - - {id: 8, cat: TV/Anime, desc: "Hentai"} - - {id: 9, cat: XXX, desc: "OFFS"} - - modes: - search: [q] - tv-search: [q, season, ep] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: lang - type: select - label: Language - default: 0 - options: - 0: All - 4: Japanese - 5: Chinese - 6: Malaysia - 7: North Korea - 8: English - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="account-logout.php"] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - # https://fappaizuri.me/torrents-search.php?search=&c8=1&c6=1&incldead=1&freeleech=0&lang=0 - - path: torrents-search.php - - inputs: - $raw: "{{ range .Categories }}&c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 notfree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - lang: "{{ .Config.lang }}" - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - rows: - selector: tr.t-row - - fields: - category: - selector: a[href^="torrents-search.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="torrents-details.php?id="] > b - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="torrents-details.php?id="] - attribute: href - poster: - selector: a[href^="torrents-details.php?id="] - attribute: onMouseover - filters: - - name: regexp - args: src=(.+?)> - _language: - selector: img[src*="images/languages/"] - attribute: alt - optional: true - description: - selector: font[color="red"] - filters: - - name: append - args: "{{ if .Result._language }} - {{ .Result._language }}{{ else }}{{ end }}" - size: - selector: td:nth-child(3) - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - date: - selector: td:nth-child(8) - filters: - - name: timeago - downloadvolumefactor: - case: - img[src="images/free.png"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="images/doubleupload.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 4 days (as seconds = 4 x 24 x 60 x 60) - text: 345600 -# evolution diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fearnopeer.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fearnopeer.yml deleted file mode 100644 index 504965b2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fearnopeer.yml +++ /dev/null @@ -1,195 +0,0 @@ ---- -id: fearnopeer -name: FearNoPeer -description: "FearNoPear is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://fearnopeer.com/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: PC/Games, desc: "Games"} - - {id: 5, cat: PC/0day, desc: "Apps"} - - {id: 6, cat: TV/Anime, desc: "Anime"} - - {id: 9, cat: TV/Sport, desc: "Sport"} - - {id: 14, cat: Other, desc: "Tutorials"} - - {id: 11, cat: Other, desc: "Assorted"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your FearNoPeer account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts that have not logged in for 150 days will be disabled, and may be deleted shortly afterwards." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 0.81 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/femdomcult.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/femdomcult.yml deleted file mode 100644 index 6836605c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/femdomcult.yml +++ /dev/null @@ -1,164 +0,0 @@ -# Femdomcult uses the Luminance code, which is based on gazelle -# ajax.php API additionally needs the auth=$authkey argument -# Because the API is e.g. missing poster images we use screen scraping instead of the generic gazelle based approach ---- -id: femdomcult -name: Femdomcult -description: "Femdomcult is a Private Torrent Tracker for FETISH 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://femdomcult.org/ - -caps: - categorymappings: - - {id: 58, cat: XXX, desc: "Amateur/Mainstream"} - - {id: 60, cat: XXX, desc: "Anime Comics"} - - {id: 73, cat: XXX, desc: "Asian Femdom"} - - {id: 61, cat: XXX, desc: "Audio"} - - {id: 78, cat: XXX, desc: "EBook"} - - {id: 74, cat: XXX, desc: "Female Muscles"} - - {id: 59, cat: XXX, desc: "Femdom"} - - {id: 62, cat: XXX, desc: "Femdom Vip"} - - {id: 63, cat: XXX, desc: "Fetish"} - - {id: 68, cat: XXX, desc: "Fetish Vip"} - - {id: 76, cat: XXX, desc: "Game"} - - {id: 64, cat: XXX, desc: "Gts-Vore-Crush"} - - {id: 72, cat: XXX, desc: "Images"} - - {id: 69, cat: XXX, desc: "Lezdom"} - - {id: 70, cat: XXX, desc: "Pov"} - - {id: 65, cat: XXX, desc: "Scat"} - - {id: 71, cat: XXX, desc: "Strapon"} - - {id: 66, cat: XXX, desc: "Transdom"} - - {id: 77, cat: XXX, desc: "Unknown & Low Quality"} - - {id: 75, cat: XXX, desc: "Virtual Reality"} - - {id: 0, cat: XXX/Other, desc: "Other"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "User classes Beginner and Perv have to be inactive (= not logged in on the site, tracker activity/seeding does not count) for 4 months before getting disabled automatically. Member class and higher are exempt." - -login: - path: login - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cinfo: "2550|1350|24|-60" - keeplogged: 1 - error: - - selector: form#loginform > span.warning - test: - path: torrents.php - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - title: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - download: - selector: a[href^="/torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon[title*="okay"]: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - title: - selector: a[href^="/torrents.php?id="] - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "(\\d+)]=1" - details: - selector: a[href^="/torrents.php?id="] - attribute: href - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=(.*?)>" - - name: re_replace - args: ["[\\\\\"]", ""] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - i.unlimited_leech: 0 - i.sitewide_leech: 0 - img[alt="Freeleech"]: 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - img[alt="DoubleSeed"]: 2 - "*": 1 - minimumratio: - text: 0.5 - # MR is reduced but not removed if MST is met - # minimumseedtime: - # # 3 days (as seconds = 3 x 24 x 60 x 60) - # text: 259200 -# luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fenyarnyek-tracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fenyarnyek-tracker.yml deleted file mode 100644 index da4415fe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fenyarnyek-tracker.yml +++ /dev/null @@ -1,148 +0,0 @@ ---- -id: fenyarnyek-tracker -name: Fenyarnyek-Tracker -description: "Fenyarnyek-Tracker is a HUNGARIAN Semi-Private site for TV / MOVIES / GENERAL" -language: hu-HU -type: semi-private -encoding: UTF-8 -links: - - http://tracker.fenyarnyek.com/ -legacylinks: - - https://tracker.fenyarnyek.com/ # site is forcing http - -caps: - categorymappings: - - {id: 13, cat: Movies/SD, desc: "SD Hun"} - - {id: 14, cat: Movies/SD, desc: "SD Eng"} - - {id: 15, cat: Movies/SD, desc: "Xvid Hun"} - - {id: 16, cat: Movies/SD, desc: "Xvid Eng"} - - {id: 17, cat: Movies/HD, desc: "HD Hun"} - - {id: 18, cat: Movies/HD, desc: "HD Eng"} - - {id: 19, cat: Movies/DVD, desc: "DVD Hun"} - - {id: 20, cat: Movies/DVD, desc: "DVD Eng"} - - {id: 21, cat: Movies/SD, desc: "Cam Hun"} - - {id: 22, cat: Movies/SD, desc: "Cam Eng"} - - {id: 23, cat: TV, desc: "Sorozat Hun"} - - {id: 24, cat: TV, desc: "Sorozat Eng"} - - {id: 25, cat: TV/Anime, desc: "Animációs"} - - {id: 26, cat: Books, desc: "Ebook Hun"} - - {id: 27, cat: Books, desc: "Ebook Eng"} - - {id: 28, cat: PC/Games, desc: "Game Iso"} - - {id: 29, cat: PC/Games, desc: "Game Rip"} - - {id: 30, cat: XXX, desc: "XXX Film"} - - {id: 31, cat: XXX/ImageSet, desc: "XXX kép"} - - {id: 32, cat: Audio, desc: "Zene Hun"} - - {id: 33, cat: Audio, desc: "Zene Eng"} - - {id: 34, cat: Other, desc: "Képek"} - - {id: 35, cat: PC/Mobile-Other, desc: "Mobil"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - method: post - path: index.php?page=login - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: td.lista span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # http://tracker.fenyarnyek.com/index.php?page=torrents&search=&category=0&active=0 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - # does not support multi categories, defaulting to All. - category: 0 - # 0 all, 1 activeonly, 2 deadonly - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.lista > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href*="category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details"] - details: - selector: a[href^="index.php?page=torrent-details"] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: a[href^="index.php?page=torrent-details"] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - date: - selector: td:nth-child(4) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd/MM/yyyy" - size: - selector: a[href^="index.php?page=torrent-details"] - attribute: onmouseover - filters: - - name: regexp - args: "Méret: (.+?)<" - seeders: - selector: td:nth-last-child(5) - leechers: - selector: td:nth-last-child(6) - grabs: - selector: td:nth-last-child(7) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# xbtit diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/file-tracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/file-tracker.yml deleted file mode 100644 index 06eabec3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/file-tracker.yml +++ /dev/null @@ -1,705 +0,0 @@ ---- -id: file-tracker -name: File-Tracker -description: "File Tracker is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / GENERAL" -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - https://file-tracker.net/ - -caps: - categorymappings: - # Эксклюзив - - {id: 387, cat: Movies/DVD, desc: " |- Отечественные фильмы DVD Master's"} - - {id: 386, cat: Movies/DVD, desc: " |- Зарубежные фильмы DVD Master's"} - - {id: 390, cat: TV/SD, desc: " |- Сериалы DVD Master's"} - - {id: 388, cat: Movies/DVD, desc: " |- Мультфильмы DVD Master's"} - - {id: 601, cat: TV/Anime, desc: " |- Мультсериалы DVD Master's"} - # Кино, видео, мультипликация и ТВ - - {id: 1002, cat: Movies, desc: " |- Зарубежные фильмы , мультфильмы [HD/UHD]"} - - {id: 987, cat: Movies, desc: " |- Зарубежные фильмы , мультфильмы [SD]"} - - {id: 1003, cat: Movies, desc: " |- Отечественные фильмы , мультфильмы [HD/UHD]"} - - {id: 988, cat: Movies, desc: " |- Отечественные фильмы , мультфильмы [SD]"} - - {id: 337, cat: Movies, desc: " |- Зарубежные фильмы , мультфильмы на других языках [SD/HD/UHD]"} - - {id: 994, cat: Movies, desc: " |- Новинки с рекламой (фильмы, мультфильмы) [SD/HD/UHD]"} - - {id: 385, cat: Movies, desc: " |- Экранки (фильмы , мультфильмы) [SD/HD/UHD]"} - - {id: 999, cat: Movies, desc: " |- Фильмы (2021-2022)"} - - {id: 996, cat: Movies, desc: " |- Фильмы (2019-2020)"} - - {id: 921, cat: Movies, desc: " |- Фильмы (2016-2018)"} - - {id: 382, cat: Movies, desc: " |- Фильмы (2012-2015)"} - - {id: 381, cat: Movies, desc: " |- Фильмы (2009-2011)"} - - {id: 734, cat: Movies, desc: " |- Фильмы (2006-2008)"} - - {id: 380, cat: Movies, desc: " |- Фильмы (2001-2005)"} - - {id: 379, cat: Movies, desc: " |- Фильмы до 2000 года"} - - {id: 613, cat: Movies, desc: " |- Кино для детей"} - - {id: 638, cat: Movies, desc: " |- Азиатское кино/Япония"} - - {id: 136, cat: Movies, desc: " |- Индийское кино"} - - {id: 443, cat: Movies, desc: " |- Антология зарубежного кино"} - - {id: 1000, cat: Movies, desc: " |- Наше кино (2021 - 2022)"} - - {id: 997, cat: Movies, desc: " |- Наше кино (2019 - 2020)"} - - {id: 922, cat: Movies, desc: " |- Наше кино (2016 - 2018)"} - - {id: 229, cat: Movies, desc: " |- Наше кино (2012 - 2015)"} - - {id: 733, cat: Movies, desc: " |- Наше кино (2008 - 2011)"} - - {id: 334, cat: Movies, desc: " |- Наше кино (2001 - 2007)"} - - {id: 333, cat: Movies, desc: " |- Наше кино (1990 - 2000)"} - - {id: 250, cat: Movies, desc: " |- Советское кино (классика)"} - - {id: 62, cat: Movies, desc: " |- Кино для детей"} - - {id: 962, cat: Movies, desc: " |- Антология отечественного кино"} - - {id: 990, cat: Movies, desc: " |- Фильмы , мультфильмы и сериалы на языках ближнего..."} - - {id: 991, cat: Movies, desc: " |- Зарубежные фильмы , мультфильмы и сериалы в оригинале"} - - {id: 74, cat: Movies, desc: " |- Зарубежные мультфильмы"} - - {id: 73, cat: Movies, desc: " |- Отечественные мультфильмы"} - - {id: 599, cat: TV, desc: " |- Сказки русских писателей"} - - {id: 575, cat: TV, desc: " |- Интерны"} - - {id: 271, cat: TV, desc: " |- Реальные пацаны"} - - {id: 377, cat: TV, desc: " |- МарГоша"} - - {id: 612, cat: TV, desc: " |- Воронины"} - - {id: 731, cat: TV, desc: " |- Сваты"} - - {id: 968, cat: TV, desc: " |- Разные сериалы"} - - {id: 639, cat: TV, desc: " |- Теория большого взрыва"} - - {id: 89, cat: TV, desc: " |- Друзья"} - - {id: 92, cat: TV, desc: " |- Тайны Смолвиля"} - - {id: 95, cat: TV, desc: " |- Секретные материалы"} - - {id: 383, cat: TV, desc: " |- Альф"} - - {id: 384, cat: TV, desc: " |- Доктор Хаус"} - - {id: 632, cat: TV, desc: " |- Дневники Вампира"} - - {id: 969, cat: TV, desc: " |- Разные сериалы"} - - {id: 600, cat: TV, desc: " |- Том и Джерри"} - - {id: 609, cat: TV, desc: " |- Разное (иностранные мультсериалы)"} - - {id: 701, cat: TV, desc: " |- Маша и Медведь"} - - {id: 594, cat: TV, desc: " |- Ну, погоди!"} - - {id: 598, cat: TV, desc: " |- Смешарики"} - - {id: 610, cat: TV, desc: " |- Сборники мультфильмов"} - - {id: 870, cat: TV, desc: " |- Разные (отечественные мультсериалы)"} - - {id: 741, cat: Movies/3D, desc: " |- 3D | Кинофильмы"} - - {id: 742, cat: Movies/3D, desc: " |- 3D | Мультфильмы"} - - {id: 981, cat: Movies/3D, desc: " |- 3D | Blu-ray и BD Remux"} - - {id: 743, cat: Movies/3D, desc: " |- 3D | Документальные фильмы"} - - {id: 744, cat: Movies/3D, desc: " |- 3D | Спорт"} - - {id: 335, cat: Movies/DVD, desc: " |- Наше кино (DVD)"} - - {id: 336, cat: Movies/HD, desc: " |- Наше кино (HD Video)"} - - {id: 747, cat: Movies/BluRay, desc: " |- Наше кино (Blu-ray и BD Remux) [HD/UHD]"} - - {id: 985, cat: Movies/UHD, desc: " |- Наше кино (Ultra HD)"} - - {id: 69, cat: Movies/DVD, desc: " |- Зарубежное кино (DVD)"} - - {id: 70, cat: Movies/HD, desc: " |- Зарубежное кино (HD Video)"} - - {id: 748, cat: Movies/BluRay, desc: " |- Зарубежное кино (Blu-ray и BD Remux) [HD/UHD]"} - - {id: 984, cat: Movies/UHD, desc: " |- Зарубежное кино (Ultra HD)"} - - {id: 907, cat: Movies, desc: " |- Кино для детей (DVD, HD Video, Blu-ray, BD Remux, Ultra HD)"} - - {id: 339, cat: Movies, desc: " |- Мультфильмы (DVD, HD Video и Blu-ray)"} - - {id: 602, cat: TV/Anime, desc: " |- Мультсериалы (DVD, HD Video и Blu-ray)"} - - {id: 906, cat: Movies, desc: " |- Азиатское кино/Япония (DVD, HD Video, Blu-ray, BD Remux, Ult.."} - - {id: 442, cat: Movies, desc: " |- Антология (DVD, HD Video, Blu-ray, BD Remux)"} - - {id: 898, cat: TV, desc: " |- Отечественные сериалы (DVD, HD Video, Blu-ray, BD Remux)"} - - {id: 970, cat: TV, desc: " |- Иностранные сериалы (DVD, HD Video, Blu-ray, BD Remux)"} - - {id: 71, cat: Movies, desc: " |- Арт-хаус и авторское кино (DVD, HD Video, Blu-ray, BD Remux)"} - - {id: 180, cat: Audio/Video, desc: " |- Музыкальное (DVD, HD Video, Blu-ray, BD Remux)"} - - {id: 681, cat: TV, desc: " |- Осторожно Модерн!"} - - {id: 341, cat: TV, desc: " |- Ералаш"} - - {id: 332, cat: TV, desc: " |- Прожекторпэрисхилтон"} - - {id: 330, cat: TV, desc: " |- Маски-шоу"} - - {id: 279, cat: TV, desc: " |- Юмористы"} - - {id: 65, cat: TV, desc: " |- КВН / Пост КВН"} - - {id: 66, cat: TV, desc: " |- Вечерний Квартал"} - - {id: 64, cat: TV, desc: " |- Даешь,молодежь!"} - - {id: 10, cat: TV, desc: " |- Наша Russia"} - - {id: 879, cat: TV, desc: " |- Другие телепередачи"} - - {id: 614, cat: TV, desc: " |- Строительство и дизайн"} - - {id: 252, cat: TV, desc: " |- BBC"} - - {id: 81, cat: TV, desc: " |- Discovery"} - - {id: 80, cat: TV, desc: " |- Фильмы National Geographic"} - - {id: 910, cat: TV, desc: " |- Флора и Фауна"} - - {id: 573, cat: TV, desc: " |- Секретные файлы"} - - {id: 359, cat: TV, desc: " |- Запретные темы истории"} - - {id: 358, cat: TV, desc: " |- Секретные материалы"} - - {id: 357, cat: TV, desc: " |- Русские сенсации"} - - {id: 356, cat: TV, desc: " |- Среда обитания"} - - {id: 355, cat: TV, desc: " |- Медицина и здоровье"} - - {id: 354, cat: TV, desc: " |- Тайны века"} - - {id: 351, cat: TV, desc: " |- Апокалипсис"} - - {id: 350, cat: TV, desc: " |- Профессия репортёр"} - - {id: 349, cat: TV, desc: " |- Криминальная Россия"} - - {id: 251, cat: TV, desc: " |- Военное видео и вооружение"} - - {id: 82, cat: TV, desc: " |- Все о Охоте и Рыбалке"} - - {id: 79, cat: TV, desc: " |- Непознанное и сверхъестественное"} - - {id: 474, cat: TV, desc: " |- Одиссея Жака Кусто"} - - {id: 268, cat: TV, desc: " |- Разное"} - - {id: 365, cat: TV/Sport, desc: " |- Олимпийские игры"} - - {id: 364, cat: TV/Sport, desc: " |- Фигурное катание"} - - {id: 363, cat: TV/Sport, desc: " |- Бодибилдинг/Силовые виды спорта"} - - {id: 362, cat: TV/Sport, desc: " |- Бильярд"} - - {id: 361, cat: TV/Sport, desc: " |- Волейбол/Гандбол"} - - {id: 360, cat: TV/Sport, desc: " |- Формула 1"} - - {id: 272, cat: TV/Sport, desc: " |- Футбол"} - - {id: 249, cat: TV/Sport, desc: " |- Рестлинг"} - - {id: 248, cat: TV/Sport, desc: " |- Скейтборд / Сноуборд / Паркур"} - - {id: 220, cat: TV/Sport, desc: " |- Авто/Мото"} - - {id: 219, cat: TV/Sport, desc: " |- Хоккей"} - - {id: 218, cat: TV/Sport, desc: " |- Баскетбол"} - - {id: 217, cat: TV/Sport, desc: " |- Бокс"} - - {id: 216, cat: TV/Sport, desc: " |- Теннис"} - - {id: 215, cat: TV/Sport, desc: " |- Экстрим"} - - {id: 214, cat: TV/Sport, desc: " |- Бои без правил"} - - {id: 468, cat: TV/Sport, desc: " |- Игровое видео"} - - {id: 545, cat: TV, desc: " |- Видеоуроки по программному обеспечению"} - - {id: 673, cat: TV, desc: " |- Видеоуроки по медицине (обучение, лечение, профилактика)"} - - {id: 463, cat: TV, desc: " |- Видеообучение ин. языкам"} - - {id: 735, cat: TV, desc: " |- Для родителей"} - - {id: 702, cat: TV, desc: " |- Для студентов"} - - {id: 678, cat: TV, desc: " |- Для школьного возраста"} - - {id: 677, cat: TV, desc: " |- Для самых маленьких"} - - {id: 200, cat: TV, desc: " |- Русский стиль и рукопашный бой"} - - {id: 199, cat: TV, desc: " |- Джиу-джитсу"} - - {id: 198, cat: TV, desc: " |- Айкидо и айки-дзюцу"} - - {id: 277, cat: TV, desc: "Разное (Видеоуроки, обучающее видео)"} - - {id: 657, cat: TV, desc: " |- Школа || Сёнэн || Сёдзё"} - - {id: 656, cat: TV, desc: " |- Боевые искусства"} - - {id: 655, cat: TV, desc: " |- Ужасы || Мистика || Вампиры"} - - {id: 654, cat: TV, desc: " |- Фэнтези || Апокалиптика || Постапокалиптика"} - - {id: 653, cat: TV, desc: " |- Меха"} - - {id: 658, cat: TV, desc: " |- Романтика"} - - {id: 659, cat: TV, desc: " |- Комедия"} - - {id: 660, cat: TV, desc: " |- Для детей"} - - {id: 661, cat: TV, desc: " |- Разное"} - - {id: 620, cat: TV, desc: " |- Призрак в доспехах"} - - {id: 635, cat: TV, desc: " |- Хеллсинг"} - - {id: 619, cat: TV, desc: " |- Мобильный воин ГАНДАМ"} - - {id: 618, cat: TV, desc: " |- Наруто"} - - {id: 617, cat: TV, desc: " |- Блич"} - - {id: 308, cat: TV/Anime, desc: " |- Аниме (озвучка/SD и DVD)"} - - {id: 307, cat: TV/Anime, desc: " |- Аниме (RAW/DVD)"} - - {id: 306, cat: TV/Anime, desc: " |- Аниме (RAW/SD)"} - - {id: 715, cat: TV/Anime, desc: " |- Аниме (DVD)"} - - {id: 317, cat: TV/Anime, desc: " |- 720p и 1080p (озвучка)"} - - {id: 316, cat: TV/Anime, desc: " |- 1080p (RAW)"} - - {id: 315, cat: TV/Anime, desc: " |- 720p (RAW)"} - - {id: 304, cat: TV/Anime, desc: " |- Аниме (озвучка)"} - - {id: 303, cat: TV/Anime, desc: " |- Аниме (RAW)"} - # Игры - - {id: 471, cat: Console, desc: " |- Раздачи игр от R.G. FTN Gaming RePack"} - - {id: 19, cat: Console, desc: " |- Новинки игр"} - - {id: 99, cat: Console, desc: " |- Demo, Alpha, Beta версии"} - - {id: 16, cat: Console, desc: " |- Action FPS (1st Person)"} - - {id: 885, cat: Console, desc: " |- Action TPS (3rd Person)"} - - {id: 17, cat: Console, desc: " |- RPG"} - - {id: 104, cat: Console, desc: " |- Авто/мото симуляторы"} - - {id: 105, cat: Console, desc: " |- Авиасимуляторы"} - - {id: 923, cat: Console, desc: " |- Спортивные симуляторы"} - - {id: 258, cat: Console, desc: " |- Другие симуляторы"} - - {id: 102, cat: Console, desc: " |- Аркады, Файтинги"} - - {id: 103, cat: Console, desc: " |- Приключения и Квесты"} - - {id: 101, cat: Console, desc: " |- Стратегии / Логические игры"} - - {id: 108, cat: Console, desc: " |- Мини игры"} - - {id: 20, cat: Console, desc: " |- Online-игры"} - - {id: 107, cat: Console, desc: " |- Детские и обучающие игры"} - - {id: 109, cat: Console, desc: " |- Аниме-игры"} - - {id: 722, cat: Console, desc: " |- Action (старые игры)"} - - {id: 721, cat: Console, desc: " |- RPG (старые игры)"} - - {id: 720, cat: Console, desc: " |- Симуляторы (старые игры)"} - - {id: 719, cat: Console, desc: " |- Аркады (старые игры)"} - - {id: 718, cat: Console, desc: " |- Приключения и Квесты (старые игры)"} - - {id: 717, cat: Console, desc: " |- Стратегии (старые игры)"} - - {id: 989, cat: Console, desc: " |- Модифицированные версии игр"} - - {id: 18, cat: Console, desc: " |- Моды, русификаторы, патчи, дополнения и обновления для игр"} - - {id: 724, cat: Console, desc: " |- Игровой софт"} - - {id: 450, cat: Console, desc: " |- S.T.A.L.K.E.R."} - - {id: 920, cat: Console, desc: " |- Игры для Mac OS"} - - {id: 695, cat: Console, desc: " |- PlayStation 1/2/3"} - - {id: 113, cat: Console, desc: " |- Xbox (Xbox 360)"} - - {id: 112, cat: Console, desc: " |- PSX-PSP"} - - {id: 111, cat: Console, desc: " |- Dreamcast"} - - {id: 679, cat: Console, desc: " |- Видео для PSP"} - - {id: 662, cat: Console, desc: " |- Софт для консолей"} - - {id: 116, cat: Console, desc: " |- Портативные и Консольные (Игры)"} - # ПО для Windows - - {id: 529, cat: PC/0day, desc: " |- Информация и диагностика"} - - {id: 518, cat: PC/0day, desc: " |- Системные утилиты"} - - {id: 516, cat: PC/0day, desc: " |- Безопасность: Защита информации. Антивирусы/файерволы"} - - {id: 515, cat: PC/0day, desc: " |- Архиваторы и файловые менеджеры"} - - {id: 514, cat: PC/0day, desc: " |- Сервисное обслуживание ПК"} - - {id: 513, cat: PC/0day, desc: " |- Настройка и оптимизация ОС"} - - {id: 530, cat: PC/0day, desc: " |- Сборники смешанного ПО"} - - {id: 437, cat: PC/0day, desc: " |- Активаторы (лоадеры) OS Windows / Office, SP и оф патчи"} - - {id: 520, cat: PC/0day, desc: " |- Офис"} - - {id: 528, cat: PC/0day, desc: " |- Работа с PDF и DjVu"} - - {id: 522, cat: PC/0day, desc: " |- Программы для бизнеса и делопроизводства"} - - {id: 521, cat: PC/0day, desc: " |- Словари, Переводчики"} - - {id: 527, cat: PC/0day, desc: " |- Распознавание текста, звука и синтез речи"} - - {id: 508, cat: PC/0day, desc: " |- Консультант+"} - - {id: 509, cat: PC/0day, desc: " |- Гарант"} - - {id: 510, cat: PC/0day, desc: " |- Другие справочно-правовые системы и обучающее ПО"} - - {id: 511, cat: PC/0day, desc: " |- Справочное ПО: справочники, каталоги"} - - {id: 543, cat: PC/0day, desc: " |- Запись CD, DVD"} - - {id: 526, cat: PC/0day, desc: " |- Графика / Просмотрщики графики"} - - {id: 525, cat: PC/0day, desc: " |- Для работы с аудио"} - - {id: 524, cat: PC/0day, desc: " |- Для работы с видео"} - - {id: 887, cat: PC/0day, desc: " |- Интернет TV & Radio"} - - {id: 561, cat: PC/0day, desc: " |- Удаленный доступ и ПО для свободного доступа в интернет"} - - {id: 555, cat: PC/0day, desc: " |- 3G USB-модем и Webcam"} - - {id: 537, cat: PC/0day, desc: " |- Утилиты для удаления всплывающих окон и баннерной рекламы"} - - {id: 536, cat: PC/0day, desc: " |- Программы общения"} - - {id: 535, cat: PC/0day, desc: " |- Почта и файлообмен"} - - {id: 533, cat: PC/0day, desc: " |- Браузеры и дополнения к ним"} - - {id: 534, cat: PC/0day, desc: " |- Разное"} - - {id: 542, cat: PC/0day, desc: " |- Сборники драйверов и ПО для обновления драйверов"} - - {id: 541, cat: PC/0day, desc: " |- Звуковые карты"} - - {id: 540, cat: PC/0day, desc: " |- Видеокарты"} - - {id: 539, cat: PC/0day, desc: " |- Материнские платы"} - - {id: 554, cat: PC/0day, desc: " |- Темы, скринсейверы ..."} - - {id: 551, cat: PC/0day, desc: " |- Обои"} - - {id: 553, cat: PC/0day, desc: " |- Гаджеты рабочего стола"} - - {id: 739, cat: PC/0day, desc: " |- Аватары, Иконки, Смайлы"} - - {id: 607, cat: PC/0day, desc: " |- САПР"} - - {id: 344, cat: PC/0day, desc: " |- ГИС (Геоинформационные системы)"} - - {id: 965, cat: PC/0day, desc: " |- Программирование и разработка ПО"} - - {id: 345, cat: PC/0day, desc: " |- Карты, снабженные программной оболочкой"} - - {id: 348, cat: PC/0day, desc: " |- Разное - системы навигации и карты"} - - {id: 680, cat: PC/0day, desc: " |- ПО по диагностике и ремонту автомобилей"} - - {id: 675, cat: PC/0day, desc: " |- Программное обеспечение по медицине"} - - {id: 560, cat: PC/0day, desc: " |- Photostoсks"} - - {id: 559, cat: PC/0day, desc: " |- Футажи"} - - {id: 558, cat: PC/0day, desc: " |- Заготовки, виньетки, рамки"} - - {id: 557, cat: PC/0day, desc: " |- Растровые и Векторные Клипарты"} - - {id: 960, cat: PC/0day, desc: " |- Вспомогательный софт для релизера"} - - {id: 961, cat: PC/0day, desc: " |- Разное"} - # Операционные системы и программирование - - {id: 972, cat: PC, desc: " |- Windows 10 / 11 (Оригинальные версии)"} - - {id: 909, cat: PC, desc: " |- Windows 8 / 8.1 (Оригинальные версии)"} - - {id: 822, cat: PC, desc: " |- Windows 7 (Оригинальные версии)"} - - {id: 823, cat: PC, desc: " |- Windows Vista (Оригинальные версии)"} - - {id: 824, cat: PC, desc: " |- Windows XP (Оригинальные версии)"} - - {id: 434, cat: PC, desc: " |- Windows OS, выпущенные до 2001 года (Оригинальные версии)"} - - {id: 973, cat: PC, desc: " |- Windows 10 / 11 (сборки)"} - - {id: 883, cat: PC, desc: " |- Windows 8 / 8.1 (сборки)"} - - {id: 264, cat: PC, desc: " |- Windows 7 (сборки)"} - - {id: 166, cat: PC, desc: " |- Windows Vista (сборки)"} - - {id: 165, cat: PC, desc: " |- Windows XP (сборки)"} - - {id: 167, cat: PC, desc: " |- Серверные OS Windows"} - - {id: 998, cat: PC, desc: " |- Insider Preview, RC, Beta версии, Service Packs"} - - {id: 265, cat: PC, desc: " |- Cборники разных операционных систем"} - - {id: 281, cat: PC, desc: " |- Помощь/Опросы/Общение"} - - {id: 168, cat: PC, desc: " |- Операционные системы"} - - {id: 280, cat: PC, desc: " |- Программное обеспечение"} - - {id: 169, cat: PC, desc: " |- Другие ОС и ПО под них"} - - {id: 732, cat: PC, desc: " |- Игры для Linux"} - - {id: 328, cat: PC, desc: " |- Видеоуроки по Linux"} - - {id: 286, cat: PC, desc: " |- Общение"} - - {id: 285, cat: PC/Mac, desc: " |- Аудио редакторы и конверторы для Mac OS"} - - {id: 284, cat: PC/Mac, desc: " |- Программы для создания и обработки графики Mac OS"} - - {id: 283, cat: PC/Mac, desc: " |- Программы для просмотра и обработки видео Mac OS"} - - {id: 282, cat: PC/Mac, desc: " |- Mac OS"} - - {id: 171, cat: PC/Mac, desc: " |- Системные программы для Mac OS"} - - {id: 170, cat: PC/Mac, desc: " |- Mac OS (для Macintosh)"} - - {id: 436, cat: Other, desc: " |- WYSIWYG Редакторы для веб-дизайна"} - - {id: 435, cat: Other, desc: " |- Обмен исходниками"} - - {id: 376, cat: Other, desc: " |- Среды программирования, компиляторы и вспомогательные програ.."} - - {id: 370, cat: Other, desc: " |- Системы управления базами данных"} - - {id: 369, cat: Other, desc: " |- Скрипты и шаблоны"} - - {id: 368, cat: Other, desc: " |- Компоненты для сред программирования"} - - {id: 367, cat: Other, desc: " |- Ищу и предлагаю (Веб-разработка и программирование)"} - # iPhone, iPod, iPod Touch, iPad, мобильные телефоны . Android и КПК - - {id: 430, cat: PC/Mobile-iOS, desc: " |- База знаний"} - - {id: 967, cat: PC/Mobile-iOS, desc: " |- Прошивки и ОС"} - - {id: 426, cat: PC/Mobile-iOS, desc: " |- Программы"} - - {id: 429, cat: PC/Mobile-iOS, desc: " |- Игры"} - - {id: 428, cat: PC/Mobile-iOS, desc: " |- Видео"} - - {id: 913, cat: PC/Mobile-iOS, desc: " |- Видео"} - - {id: 175, cat: PC/Mobile-iOS, desc: " |- Приложения"} - - {id: 174, cat: PC/Mobile-iOS, desc: " |- Игры"} - # Музыка - - {id: 939, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 948, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 129, cat: Audio, desc: " |- Многоканальная музыка (Электронная музыка)"} - - {id: 825, cat: Audio/Lossless, desc: " |- Lossless (Электронная Музыка)"} - - {id: 829, cat: Audio/MP3, desc: " |- Techno / Hardcore / Acid (mp3)"} - - {id: 828, cat: Audio/MP3, desc: " |- Radioshows, Podcasts, Live Sets, Mixes (mp3)"} - - {id: 827, cat: Audio/MP3, desc: " |- D'n'B / Jungle / Breaks (mp3)"} - - {id: 826, cat: Audio/MP3, desc: " |- Ambient / ChillOut (mp3)"} - - {id: 288, cat: Audio/MP3, desc: " |- Drum and Bass (mp3)"} - - {id: 128, cat: Audio/MP3, desc: " |- Garage (mp3)"} - - {id: 127, cat: Audio/MP3, desc: " |- Mixadance (mp3)"} - - {id: 126, cat: Audio/MP3, desc: " |- House / Electro / Dance / Synthpop (mp3)"} - - {id: 125, cat: Audio/MP3, desc: " |- Trance / ProgressiveTranсе / HardStyle (mp3)"} - - {id: 124, cat: Audio/MP3, desc: " |- Электронная музыка других жанров (mp3)"} - - {id: 929, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 940, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 951, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 130, cat: Audio, desc: " |- Многоканальная музыка (Рэп, Хип-Хоп, R.n.B)"} - - {id: 830, cat: Audio/Lossless, desc: " |- Lossless (Рэп, Хип-Хоп, R.n.B)"} - - {id: 134, cat: Audio/MP3, desc: " |- Отечественный Рэп, Хип-Хоп (mр3)"} - - {id: 133, cat: Audio/MP3, desc: " |- Зарубежный Рэп, Хип-Хоп (mр3)"} - - {id: 132, cat: Audio/MP3, desc: " |- Отечественный R.n.B (mр3)"} - - {id: 131, cat: Audio/MP3, desc: " |- Зарубежный R.n.B (mр3)"} - - {id: 930, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 941, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 952, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 831, cat: Audio, desc: " |- Многоканальная музыка (Зарубежный рок)"} - - {id: 454, cat: Audio/Lossless, desc: " |- Lossless (Зарубежный рок)"} - - {id: 149, cat: Audio/MP3, desc: " |- Аltеrnаtivе, Hard, Indереndеnt (mp3)"} - - {id: 839, cat: Audio/MP3, desc: " |- Reggae, Ska, Dub (mp3)"} - - {id: 148, cat: Audio/MP3, desc: " |- Punk, Hardcore, Emo (mp3)"} - - {id: 147, cat: Audio/MP3, desc: " |- Rock, Metalcore, Metal (mp3)"} - - {id: 931, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 942, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 954, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 832, cat: Audio, desc: " |- Многоканальная музыка (Отечественный рок)"} - - {id: 455, cat: Audio/Lossless, desc: " |- Lossless (Отечественный рок)"} - - {id: 834, cat: Audio/MP3, desc: " |- Nu Metal, Metalcore, Metal (mp3)"} - - {id: 833, cat: Audio/MP3, desc: " |- Аltеrnаtivе, Hard, Indереndеnt (mp3)"} - - {id: 152, cat: Audio/MP3, desc: " |- Punk, Hardcore, Emo (mp3)"} - - {id: 151, cat: Audio/MP3, desc: " |- Reggae, Ska, Dub (mp3)"} - - {id: 932, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 943, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 956, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 845, cat: Audio, desc: " |- Многоканальная музыка (Industrial)"} - - {id: 844, cat: Audio/Lossless, desc: " |- Lossless (Industrial)"} - - {id: 843, cat: Audio/MP3, desc: " |- Отечественный Industrial (mp3)"} - - {id: 842, cat: Audio/MP3, desc: " |- Зарубежный Industrial (mp3)"} - - {id: 936, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 944, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 955, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 853, cat: Audio, desc: " |- Многоканальная музыка (Folk, NewAge, Flamenco)"} - - {id: 852, cat: Audio/Lossless, desc: " |- Lossless (Folk, NewAge, Flamenco)"} - - {id: 851, cat: Audio/MP3, desc: " |- Flamenco и акустическая гитара (mp3)"} - - {id: 850, cat: Audio/MP3, desc: " |- Этническая музыка Африки и Азии (mp3)"} - - {id: 849, cat: Audio/MP3, desc: " |- Country, Bluegrass (mp3)"} - - {id: 848, cat: Audio/MP3, desc: " |- Этническая музыка Кавказа и Закавказья (mp3)"} - - {id: 847, cat: Audio/MP3, desc: " |- NewAge & Meditative (mp3)"} - - {id: 846, cat: Audio/MP3, desc: " |- Folk и neo-folk, celtic, medieval (mp3)"} - - {id: 935, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 945, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 953, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 222, cat: Audio, desc: " |- Многоканальная музыка (Поп-музыка)"} - - {id: 221, cat: Audio/Lossless, desc: " |- Lossless (Поп-музыка)"} - - {id: 326, cat: Audio/MP3, desc: " |- Отечественная Eurodance, Technopop, Disco (mp3)"} - - {id: 223, cat: Audio/MP3, desc: " |- Зарубежная Eurodance, Technopop, Disco (mp3)"} - - {id: 156, cat: Audio/MP3, desc: " |- Отечественная Поп музыка (mp3)"} - - {id: 157, cat: Audio/MP3, desc: " |- Зарубежная Поп музыка (mp3)"} - - {id: 158, cat: Audio/MP3, desc: " |- Сборники отечественной Поп-музыки (mp3)"} - - {id: 224, cat: Audio/MP3, desc: " |- Сборники зарубежной Поп-музыки (mp3)"} - - {id: 924, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 946, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 950, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 840, cat: Audio, desc: " |- Многоканальная музыка (Классическая музыка)"} - - {id: 841, cat: Audio/Lossless, desc: " |- Lossless (Классическая музыка)"} - - {id: 153, cat: Audio/MP3, desc: " |- Классическая музыка (mp3)"} - - {id: 933, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 947, cat: Audio/Lossless, desc: " |- Дискографии в Lossless"} - - {id: 949, cat: Audio/MP3, desc: " |- Дискографии в MP3"} - - {id: 928, cat: Audio, desc: " |- Многоканальная музыка"} - - {id: 938, cat: Audio/Lossless, desc: " |- Lossless (Музыка других жанров)"} - - {id: 572, cat: Audio/MP3, desc: " |- Blues, Lounge,Cillout, Bossa Nova (mp3)"} - - {id: 155, cat: Audio/MP3, desc: " |- Джаз (mp3)"} - - {id: 871, cat: Audio/MP3, desc: " |- Военная и Патриотическая песня (mp3)"} - - {id: 259, cat: Audio/MP3, desc: " |- Шансон и Бардовская песня (mp3)"} - - {id: 448, cat: Audio/MP3, desc: " |- Музыка для детей (mp3)"} - - {id: 934, cat: Audio/MP3, desc: " |- Сборники разных исполнителей (mp3)"} - - {id: 867, cat: Audio, desc: " |- Рок-музыка (муз видео)"} - - {id: 866, cat: Audio, desc: " |- Электронная музыка (муз видео)"} - - {id: 865, cat: Audio, desc: " |- Поп-музыка ( муз видео)"} - - {id: 864, cat: Audio, desc: " |- Народная музыка (Folk) (муз видео)"} - - {id: 863, cat: Audio, desc: " |- Джаз (муз видео)"} - - {id: 862, cat: Audio, desc: " |- Блюз (муз видео)"} - - {id: 861, cat: Audio, desc: " |- Классика (муз видео)"} - - {id: 860, cat: Audio, desc: " |- Рэп, Хип-Хоп, R.n.B (муз видео)"} - - {id: 859, cat: Audio, desc: " |- Русский шансон (муз видео)"} - - {id: 858, cat: Audio, desc: " |- Детское муз видео"} - - {id: 289, cat: Audio, desc: " |- К играм"} - - {id: 290, cat: Audio, desc: " |- К фильмам"} - - {id: 291, cat: Audio, desc: " |- К сериалам"} - # Литература и обучение - - {id: 61, cat: Books, desc: " |- Художественная литература"} - - {id: 664, cat: Books, desc: " |- Художественная литература для детей"} - - {id: 467, cat: Books, desc: " |- Художественная литература на иностранных языках"} - - {id: 703, cat: Books, desc: "Программы и библиотеки для электронных книг"} - - {id: 966, cat: Books, desc: " |- Кулинария"} - - {id: 666, cat: Books, desc: " |- Обучающая литература для родителей"} - - {id: 665, cat: Books, desc: " |- Обучающая литература для детей"} - - {id: 182, cat: Books, desc: " |- Учебники, справочники, энциклопедии"} - - {id: 466, cat: Books, desc: " |- Книги, учебники и справочная литература по обучению ин. язык.."} - - {id: 185, cat: Books, desc: " |- Философия и религия"} - - {id: 184, cat: Books, desc: " |- Психология"} - - {id: 183, cat: Books, desc: " |- Хобби (увлечения и досуг)"} - - {id: 181, cat: Books, desc: " |- Литература по компьютерам и ПО"} - - {id: 782, cat: Books, desc: " |- Радиоэлектроника"} - - {id: 585, cat: Books, desc: " |- Литература по авто и мото транспорту"} - - {id: 584, cat: Books, desc: " |- Руководства по ремонту/обслуживанию/эксплуатации авто"} - - {id: 586, cat: Books, desc: " |- Авто - Тюнинг, чиптюнинг, настройка, отделка и укращения"} - - {id: 672, cat: Books, desc: " |- Литература по медицине"} - - {id: 793, cat: Books, desc: "Журналы научно-прикладные и познавательно-образовательные"} - - {id: 708, cat: PC, desc: "ЖИнтерактивный и компьтерный материал"} - - {id: 875, cat: PC, desc: " |- Интерактивные компьютерные диски для хобби"} - - {id: 583, cat: PC, desc: " |- Интерактивные справочники и каталоги для авто/мото транспорт.."} - - {id: 465, cat: PC, desc: " |- Интерактивные и компьютерные диски для обучения ин. языкам"} - - {id: 674, cat: PC, desc: " |- Интерактивные и компьютерные диски по медицине"} - - {id: 710, cat: PC, desc: " |- Прочие материалы для детей и родителей"} - - {id: 888, cat: Books, desc: "Литература разных направлений"} - - {id: 897, cat: Books, desc: " |- Прочее"} - - {id: 896, cat: Books, desc: " |- Автомобили"} - - {id: 895, cat: Books, desc: " |- Домашним мастерам"} - - {id: 894, cat: Books, desc: " |- Для женщин"} - - {id: 893, cat: Books, desc: " |- Для мужчин"} - - {id: 892, cat: Books, desc: " |- Научно-популярное"} - - {id: 891, cat: Books, desc: " |- Мир техники"} - - {id: 890, cat: Books, desc: " |- Игры"} - - {id: 889, cat: Books, desc: " |- История"} - - {id: 21, cat: Audio/Audiobook, desc: "Аудиокниги"} - - {id: 736, cat: Audio/Audiobook, desc: " |- S.T.A.L.K.E.R."} - - {id: 693, cat: Audio/Audiobook, desc: " |- Романы"} - - {id: 464, cat: Audio/Audiobook, desc: " |- Аудиообучение ин. языкам"} - - {id: 686, cat: Audio/Audiobook, desc: " |- Обучающие и познавательные аудиокниги"} - - {id: 685, cat: Audio/Audiobook, desc: " |- Аудиокниги на иностранных языках"} - - {id: 684, cat: Audio/Audiobook, desc: " |- Детективы"} - - {id: 441, cat: Audio/Audiobook, desc: " |- Фантастика"} - - {id: 192, cat: Audio/Audiobook, desc: " |- Классика"} - - {id: 816, cat: Audio/Audiobook, desc: " |- Современная литература"} - - {id: 190, cat: Audio/Audiobook, desc: " |- Аудио книги для детей"} - - {id: 191, cat: Audio/Audiobook, desc: " |- Обучающие аудиокниги и аудиоупражнения для детей"} - - {id: 749, cat: Audio, desc: "Аудио концерты, спектакли, пьесы"} - - {id: 687, cat: Audio, desc: " |- Аудиоспектакли и литературные чтения"} - - {id: 751, cat: Audio, desc: " |- Аудио юмористические концерты (выступления)"} - # Эротика - - {id: 963, cat: XXX, desc: "Эротические художественные фильмы"} - - {id: 964, cat: XXX, desc: "Эротические документальные фильмы"} - - {id: 178, cat: XXX, desc: "Русская эротика"} - - {id: 179, cat: XXX, desc: "Иностранная эротика"} - - {id: 176, cat: XXX, desc: "Фото, ролики"} - - {id: 577, cat: XXX, desc: "Нетрадиционная эротика"} - - {id: 571, cat: XXX, desc: "Мультипликационные фильмы для взрослых"} - - {id: 177, cat: XXX, desc: "Хентай"} - - {id: 812, cat: XXX, desc: "3D | Стерео эротика"} - - {id: 882, cat: XXX, desc: " |- 3D | мульт"} - - {id: 817, cat: XXX, desc: " |- 3D | Хентай"} - - {id: 815, cat: XXX, desc: " |- 3D | Видеоролики"} - - {id: 814, cat: XXX, desc: " |- 3D | Русская эротика"} - - {id: 813, cat: XXX, desc: " |- 3D | Иностранная эротика"} - - {id: 820, cat: XXX, desc: "Литература"} - # Служебный раздел - - {id: 912, cat: Other, desc: "Нарушение правил оформления"} - - {id: 57, cat: Other, desc: "Временный склад (TEMP)"} - - {id: 433, cat: Other, desc: "Архив раздач"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: uploader - type: text - label: Search by Uploader - - name: info_uploader - type: info - label: About searching by Uploader - default: "You can search by Uploader (Author) by entering an Author username, or leave empty to get all results." - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: login.php - method: form - form: form[action$="/login.php"] - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - autologin: 1 - selectorinputs: - cookie_test: - selector: input[name="cookie_test"] - attribute: value - error: - - selector: h4.warnColor1 - test: - path: search.php - selector: a[href="/login.php?logout=1"] - -search: - paths: - - path: tracker.php - method: post - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 1 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - # sort by 1=registered 2=topicTitle 4=completed 10=seed 11=leech 12=up 13=down 5=replies 6=views 7=size 8=lastPost 9=seedLastSeen - o: "{{ .Config.sort }}" - # 1=asc 2=desc - s: "{{ .Config.type }}" - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # author - pn: "{{ if .Config.uploader }}{{ .Config.uploader }}{{ else }}{{ end }}" - # show speed - ds: 0 - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - nm: "{{ .Keywords }}" - # search all words - allw: 1 - submit: "%A0%A0Search%A0%A0" - - keywordsfilters: - - name: diacritics - args: replace - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: "tr[id^=\"tor_\"]:has(a[href^=\"/download.php?id=\"]){{ if .Config.freeleech }}:has(img[src=\"images/tor_gold.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a.gen - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.genmed, a.seedmed - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:(?:-|–)\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:(?:-|–)\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:(?:-|–)\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:(?:-|–)\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:(?:-|–)\\d+)?).+?(\\d+(?:(?:-|–)\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:(?:-|–)\\d+)?).+?(\\d+(?:(?:-|–)\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:(?:-|–)\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:(?:-|–)\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:(?:-|–)\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:(?:-|–)\\d+)?).+?(\\d+(?:(?:-|–)\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:(?:-|–)\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:(?:-|–)\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:(?:-|–)\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:(?:-|–)\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(?i)\\bКураж-Бамбей\\b", "kurazh"] - - name: re_replace - args: ["(?i)\\bКубик в Кубе\\b", "Kubik"] - - name: re_replace - args: ["(?i)\\bКравец\\b", "Kravec"] - - name: re_replace - args: ["(?i)\\bПифагор\\b", "Pifagor"] - - name: re_replace - args: ["(?i)\\bНевафильм\\b", "Nevafilm"] - - name: re_replace - args: ["(?i)\\bЛицензия\\b", "Lic"] - - name: re_replace - args: ["(?i)\\bселезень\\b", "selezen"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a.genmed, a.seedmed - attribute: href - download: - selector: a[href^="/download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - # unix - selector: td:last-child > u - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - _username: - selector: td:nth-child(5) - description: - selector: a.genmed, a.seedmed - filters: - - name: prepend - args: "Uploader: {{ .Result._username }}
" -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/filemood.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/filemood.yml deleted file mode 100644 index f4aa5064..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/filemood.yml +++ /dev/null @@ -1,77 +0,0 @@ ---- -id: filemood -name: FileMood -description: "FileMood is a Public BitTorrent DHT search engine." -language: en-US -type: public -encoding: UTF-8 -links: - - https://filemood.com/ - -caps: - categories: - Other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - -search: - paths: - - path: result - followredirect: true - - inputs: - q: "{{ .Keywords }}{{ if .Keywords }} in:title{{ else }}{{ end }}" - - keywordsfilters: - - name: tolower - - rows: - selector: table > tbody > tr:has(a.btn-success) - - fields: - category: - text: Other - title: - selector: a - attribute: title - details: - selector: a - attribute: href - infohash: - selector: a - attribute: href - filters: - - name: regexp - args: "([A-F|a-f|0-9]{40})" - size: - selector: td.dn-size - date: - text: now - seeders: - selector: td.dn-status - optional: true - default: 0 - filters: - - name: split - args: ["/", 0] - leechers: - selector: td.dn-status - optional: true - default: 0 - filters: - - name: split - args: ["/", 1] - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/finelite.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/finelite.yml deleted file mode 100644 index b6ab8b07..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/finelite.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: finelite -name: FinElite -description: "FinElite (FE) is a FINNISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fi-FI -type: private -encoding: UTF-8 -links: - - https://finelite.org/ - -settings: - - name: username - type: text - label: Username - - name: password - type: text - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 7: seeders - 5: size - 4: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_finelite - type: info - label: Login Requirement - default: "To successfully login, members must first message FinElite staff, asking that their account be whitelisted from captcha validation.

Onnistuakseen sisäänkirjautumisessa jäsenten on ensin lähetettävä viesti FinElite henkilökunnalle ja pyydettävä, että heidän tilinsä poistetaan captcha-varmennuksesta." - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Julkaisuja per sivu setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Unused IDs in the User and Power User categories are automatically deleted after 90 days. The account can be parked and stored on the site for 180 days." - -caps: - categorymappings: - - {id: 33, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 29, cat: TV/Documentary, desc: "Documentary"} - - {id: 24, cat: Books/EBook, desc: "Ebooks"} - - {id: 10, cat: Console, desc: "Games - Console"} - - {id: 11, cat: PC/Games, desc: "Games - PC"} - - {id: 26, cat: Movies/Other, desc: "Kids"} - - {id: 12, cat: Movies, desc: "Movie Pack"} - - {id: 31, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 38, cat: Movies/BluRay, desc: "Movies BD"} - - {id: 13, cat: Movies/DVD, desc: "Movies DVD-r"} - - {id: 14, cat: Movies/HD, desc: "Movies HD"} - - {id: 15, cat: Movies/SD, desc: "Movies SD"} - - {id: 22, cat: Audio/Lossless, desc: "Music FLAC"} - - {id: 23, cat: Audio/MP3, desc: "Music MP3"} - - {id: 34, cat: Audio/Video, desc: "Music video"} - - {id: 35, cat: TV/Sport, desc: "Sports"} - - {id: 37, cat: TV/UHD, desc: "TV 4K"} - - {id: 36, cat: TV/SD, desc: "TV DVD-R"} - - {id: 19, cat: TV/HD, desc: "TV HD"} - - {id: 16, cat: TV, desc: "TV Pack"} - - {id: 17, cat: TV/SD, desc: "TV SD"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -login: - path: login.php - method: form - form: form[action="/take_login.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.notice.notice-error - test: - path: / - selector: a[href="/logout.php"] - -search: - paths: - # https://finelite.org/browse.php?c14=1&c15=1&c31=1&search=tt6591554&hae=2&type=desc&cat=0&incldead=1&sort=1 - # https://finelite.org/browse.php?search=&hae=0&type=desc&cat=0&incldead=1&freeleech=yes&sort=1 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 name&descr, 1 name, 2 descr, 3 tags, 4 imdb with rating - hae: "{{ if .Query.IMDBID }}4{{ else }}1{{ end }}" - # 0 active, 1 incldead, 2 deadonly - incldead: 1 - freeleech: "{{ if .Config.freeleech }}yes{{ else }}{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table.main > tbody > tr:has(a[href^="/lataa/"]) - - fields: - category: - selector: a[href^="/browse.php?c"] - attribute: href - filters: - - name: regexp - args: c(\d+)=1 - title: - selector: a[href^="/details/"] - details: - selector: a[href^="/details/"] - attribute: href - download: - selector: a[href^="/lataa/"] - attribute: href - poster: - selector: a[href^="/details/"] img - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - # 26.3.2022
3:53 - selector: td:nth-last-child(4) - filters: - - name: re_replace - args: [" (\\d{1}):(\\d{2})", " 0$1:$2"] - - name: append - args: " +02:00" # EET - - name: dateparse - args: "d.M.yyyy HH:mm zzz" - size: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - downloadvolumefactor: - case: - span[title="Freeleech"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# FE v4.0.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/flood.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/flood.yml deleted file mode 100644 index 26e90256..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/flood.yml +++ /dev/null @@ -1,116 +0,0 @@ ---- -id: flood -name: Flood -description: "Flood (FLD) is a Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://flood.st/ - -caps: - categorymappings: - - {id: movie, cat: Movies, desc: "Movies"} - - {id: show_season, cat: TV, desc: "TV season"} - - {id: show_episode, cat: TV, desc: "TV episode"} - - modes: - search: [q] - tv-search: [q, season, ep, tmdbid, imdbid] - movie-search: [q, tmdbid, imdbid] - -settings: - - name: apikey - type: text - label: API key - - name: info_key - type: info - label: About your API key - default: "Find or generate a new API key by accessing the Security tab of your account settings." - - name: order_by - type: select - label: Sort requested from site - default: id - options: - id: created - name: title - size: size - seeders: seeders - - name: order_dir - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: api/test - method: get - -search: - paths: - - path: api/torrents - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - name: "{{ .Keywords }}" - show_season_number: "{{ .Query.Season }}" - show_episode_number: "{{ .Query.Ep }}" - tmdb_id: "{{ .Query.TMDBID }}" - imdb_id: "{{ .Query.IMDBID }}" - order_by: "{{ .Config.order_by }}" - order_dir: "{{ .Config.order_dir }}" - limit: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: items - count: - selector: total - - fields: - title: - selector: name - infohash: - selector: info_hash - category: - selector: media_type - details: - selector: main_url - download: - selector: download_url - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - description: - selector: description - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - selector: created_at - filters: - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 - downloadvolumefactor: - selector: download_accounting_factor - uploadvolumefactor: - text: 1 -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/freefarm.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/freefarm.yml deleted file mode 100644 index e0fe7870..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/freefarm.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -id: freefarm -name: Free Farm (自由农场) -description: "Free Farm (自由农场) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://pt.0ff.cc/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "Movies|电影", default: true} - - {id: 402, cat: TV, desc: "TV Series|电视剧", default: true} - - {id: 403, cat: TV/Documentary, desc: "Documentaries|纪录片", default: true} - - {id: 404, cat: TV, desc: "TV Shows|综艺", default: true} - - {id: 405, cat: TV/Anime, desc: "Animations|动漫", default: true} - - {id: 406, cat: Audio/Video, desc: "Music Videos|音乐视频", default: true} - - {id: 407, cat: Audio/Lossless, desc: "HD Music|无损音乐", default: true} - - {id: 408, cat: TV/Sport, desc: "Sports|体育", default: true} - - {id: 427, cat: Other/Misc, desc: "待定", default: true} - - {id: 412, cat: Other/Misc, desc: "待定", default: true} - - {id: 411, cat: Other/Misc, desc: "待定", default: true} - - {id: 410, cat: Other/Misc, desc: "待定", default: true} - - {id: 409, cat: Other/Misc, desc: "待定", default: true} - - {id: 428, cat: Other, desc: "Others|其他", default: true} - - {id: 413, cat: Other, desc: "Education|学习教育", default: false} - - {id: 414, cat: Other, desc: "Course|网络教程", default: false} - - {id: 415, cat: Books, desc: "book|书籍", default: false} - - {id: 416, cat: PC/0day, desc: "Resources|网络资源", default: false} - - {id: 417, cat: PC/Games, desc: "Game|游戏", default: false} - - {id: 425, cat: Other/Misc, desc: "待定", default: false} - - {id: 424, cat: Other/Misc, desc: "待定", default: false} - - {id: 423, cat: Other/Misc, desc: "待定", default: false} - - {id: 422, cat: Other/Misc, desc: "待定", default: false} - - {id: 421, cat: Other/Misc, desc: "待定", default: false} - - {id: 420, cat: Other/Misc, desc: "待定", default: false} - - {id: 419, cat: Other/Misc, desc: "待定", default: false} - - {id: 418, cat: Other/Misc, desc: "待定", default: false} - - {id: 426, cat: Other, desc: "Others|其他", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 403, 404, 405, 406, 407, 408, 427, 412, 411, 410, 409, 428] - - path: special.php - categories: [413, 414, 415, 416, 417, 425, 424, 423, 422, 421, 420, 419, 418, 426] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # special 7 days (as seconds = 7 x 24 x 60 x 60) - # normal 3 days (as seconds = 3 x 24 x 60 x 60) - case: - img[title="H&R"]: 604800 - "*": 259200 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.10.0 2026-02-01 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/freejavtorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/freejavtorrent.yml deleted file mode 100644 index 14740226..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/freejavtorrent.yml +++ /dev/null @@ -1,73 +0,0 @@ ---- -id: freejavtorrent -name: Free JAV Torrent -description: "Free JAV Torrent is a Public tracker for Asian 3X (JAV)" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.freejavtorrent.com/ - -caps: - categories: - "Japanese Movies": XXX - "Japanese Movies Packs": XXX/Pack - "Chinese Movies": XXX - "Japanese Movies SiteRip 1991-2014": XXX/Pack - "Japanese Movies SiteRip 2015-2024": XXX/Pack - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a.download-button - attribute: href - -search: - paths: - - path: "s.php?search={{ if .Keywords }}{{ .Keywords }}{{ else }}%20{{ end }}" - - rows: - selector: div.image-wrapper - - fields: - category: - selector: div.catalog a - title: - selector: a.overlay - filters: - - name: re_replace - args: ["^.+?\\] ", ""] - details: - selector: a.overlay - attribute: href - download: - selector: a.overlay - attribute: href - filters: - - name: regexp - args: "-(\\d+).html" - - name: prepend - args: "/dl.php?t=" - poster: - selector: img - attribute: src - date: - text: now - size: - selector: a.overlay - filters: - - name: regexp - args: "^\\[(\\d+(?:\\.\\d+)?\\s?[KMGT]B)\\]" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/frozenlayer.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/frozenlayer.yml deleted file mode 100644 index 7b3f2e6c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/frozenlayer.yml +++ /dev/null @@ -1,123 +0,0 @@ ---- -id: frozenlayer -name: Frozen Layer -description: "Frozen Layer is a SPANISH Public torrent site focused on ANIME" -language: es-ES -type: public -encoding: UTF-8 -requestDelay: 2 -certificates: - - 12b89038d79803d9fae15e1babf50014d6398924 # Expired 17th Feb 2026 -links: - - https://www.frozen-layer.com/ - -settings: - - name: category - type: select - label: Category - default: todos - options: - todos: todos - anime: anime - manga: manga - dorama: dorama - videojuego: videojuego - BSO: BSO - PV: PV - otros: otros - - name: info - type: info - label: Direct Downloads - default: This definition filters for search results that contain .torrent and magnet only. Results for Direct Download are not supported. - -caps: - categorymappings: - - {id: Anime, cat: TV/Anime, desc: "anime"} - - {id: Manga, cat: TV/Anime, desc: "manga"} - - {id: Dorama, cat: TV/Anime, desc: "dorama"} - - {id: Videojuego, cat: TV/Anime, desc: "videojuego"} - - {id: Bso, cat: TV/Anime, desc: "BSO"} - - {id: Pv, cat: TV/Anime, desc: "PV"} - - {id: Otros, cat: TV/Anime, desc: "otros"} - - modes: - search: [q] - tv-search: [q, season, ep] - -search: - paths: - - path: "{{ if .Keywords }}buscar/descargas/{{ .Config.category }}/{{ .Keywords }}?search=Buscar{{ else }}descargas{{ end }}" - - path: "{{ if .Keywords }}buscar/descargas/{{ .Config.category }}/{{ .Keywords }}?search=Buscar&{{ else }}descargas?{{ end }}page=2" - keywordsfilters: - - name: re_replace # remove first season from search string - args: ["(?i)(S1)", ""] - - name: re_replace # switch second season from search string - args: ["(?i)(S2)", "segunda temporada"] - - name: re_replace # switch third season from search string - args: ["(?i)(S3)", "tercera temporada"] - - name: re_replace # remove E from episode number from search string - args: ["(?i)E([0-9]+)", "$1"] - - rows: - selector: table#descargas > tbody > tr:has(td:has(a[href^="magnet:?"])) - - fields: - category: - text: "{{ if eq .Config.category \"todos\" }}Anime{{ else }}{{ .Config.category }}{{ end }}" - title: - selector: td.tit a - filters: - - name: re_replace - args: ["\\/|\\(|\\)", " "] - - name: re_replace - args: ["([A-z]*) temporada", ""] - - name: re_replace - args: ["(?i)Sprimera|Ssegunda|Stercera", ""] - - name: re_replace - args: ["S([0-9]+) - Episodio ([0-9]+)", "$2"] - - name: re_replace - args: ["- Episodio ([0-9]*)", "$1"] - - name: append - args: " Spanish" - details: - selector: td.tit a - attribute: href - download: - selector: td:nth-child(1) a[href$=".torrent"] - attribute: href - magnet: - selector: td:nth-child(1) a[href^="magnet:?"] - attribute: href - date_day: - # 1d - # 2d 3h - # 3 dias - # 3h 14m - optional: true - selector: td.fecha:not(:contains("Jan:")):not(:contains("Feb")):not(:contains("Mar:")):not(:contains("Apr")):not(:contains("May:")):not(:contains("Jun")):not(:contains("Jul:")):not(:contains("Aug")):not(:contains("Sep:")):not(:contains("Oct")):not(:contains("Nov:")):not(:contains("Dec")) - filters: - - name: replace - args: ["días", "days ago"] - - name: timeago - date_year: - # 29 Oct 17 - optional: true - selector: td.fecha:not(:contains("d")):not(:contains("h")):not(:contains("m")) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd MMM yy zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/funkytorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/funkytorrents.yml deleted file mode 100644 index b6a1e2c8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/funkytorrents.yml +++ /dev/null @@ -1,152 +0,0 @@ ---- -id: funkytorrents -name: FunkyTorrents -description: "FunkyTorrents (FT) is a Private Torrent Tracker for MUSIC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://funkytorrents.com/ -legacylinks: - - http://funkytorrents.com/ - -caps: - categorymappings: - - {id: 1, cat: PC/0day, desc: "Apps"} - - {id: 2, cat: Books/EBook, desc: "eBooks"} - - {id: 3, cat: Movies, desc: "Movie"} - - {id: 33, cat: Audio/Video, desc: "Music DVDs"} - - {id: 5, cat: Audio/Video, desc: "Music Vids"} - - {id: 4, cat: Audio, desc: "OST"} - - {id: 19, cat: Audio, desc: "Music Alternative"} - - {id: 28, cat: Audio, desc: "Music Ambient"} - - {id: 12, cat: Audio, desc: "Music Classical"} - - {id: 17, cat: Audio, desc: "Music Dance"} - - {id: 16, cat: Audio, desc: "Music Drum 'n' Bass"} - - {id: 20, cat: Audio, desc: "Music Electronic"} - - {id: 14, cat: Audio, desc: "Music Emo"} - - {id: 23, cat: Audio, desc: "Music Experimental"} - - {id: 34, cat: Audio, desc: "Music Funk"} - - {id: 18, cat: Audio, desc: "Music Hardcore"} - - {id: 13, cat: Audio, desc: "Music House"} - - {id: 31, cat: Audio, desc: "Music IDM"} - - {id: 21, cat: Audio, desc: "Music Indie"} - - {id: 26, cat: Audio, desc: "Music Industrial"} - - {id: 25, cat: Audio, desc: "Music Jazz/Blues/Soul"} - - {id: 37, cat: Audio, desc: "Music Lo-Fi"} - - {id: 6, cat: Audio, desc: "Music Metal"} - - {id: 22, cat: Audio, desc: "Music Misc"} - - {id: 7, cat: Audio, desc: "Music Pop/RnB"} - - {id: 29, cat: Audio, desc: "Music Post-Rock"} - - {id: 30, cat: Audio, desc: "Music Psychedelic"} - - {id: 8, cat: Audio, desc: "Music Punk"} - - {id: 9, cat: Audio, desc: "Music Rap/Hip-Hop"} - - {id: 35, cat: Audio, desc: "Music Reggae"} - - {id: 10, cat: Audio, desc: "Music Rock"} - - {id: 15, cat: Audio, desc: "Music Ska"} - - {id: 32, cat: Audio, desc: "Music Techno"} - - {id: 11, cat: Audio, desc: "Music Trance"} - - {id: 36, cat: Audio, desc: "Music Trip-Hop"} - - {id: 24, cat: Audio, desc: "Music World/Ethnic"} - - {id: 3010, cat: Audio/MP3, desc: "Music MP3"} - - {id: 3040, cat: Audio/Lossless, desc: "Music Lossless"} - - modes: - search: [q] - movie-search: [q] - music-search: [q, artist] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive users will be removed in 180 days, users that haven't downloaded/uploaded anything will be removed in 90 days after registration. You can park your account in your Profile to prevent it from being deleted because of inactivity if you go away on for example a vacation. When the account has been parked limits are put on the account, for example you cannot use the tracker and browse some of the pages." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("failed")) - test: - path: / - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - # cat search doesn't work if our custom MP3 and Lossless cats are selected - # $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - cat: 0 - search: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 incldead, 2 dead - incldead: 1 - # 0 all, 1 not documented, 2 free - free: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - - rows: - selector: table.mainouter > tbody > tr > td.outer > table > tbody > tr.mouse_out - - fields: - description: - selector: td:nth-child(2) > font - title: - selector: td:nth-child(2) - remove: font - details: - selector: a[href^="details.php?id="] - attribute: href - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - category|append: - optional: true - case: - a[href="/browse.php?ext=1&bitrate=Lossless"]: 3040 - a[href="/browse.php?ext=1&format=MP3"]: 3010 - download: - selector: a[href^="download.php"] - attribute: href - files: - selector: td:nth-child(5) - date: - selector: td:nth-child(7) - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - seeders: - selector: td:nth-child(10) - leechers: - selector: td:nth-child(11) - downloadvolumefactor: - case: - img[alt="Free Leech"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.6 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/futuretorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/futuretorrent.yml deleted file mode 100644 index 86535057..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/futuretorrent.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: futuretorrent -name: FutureTorrent -description: "FutureTorrent is an ITALIAN Private PAY2DL Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: private -encoding: UTF-8 -links: - - https://futuretorrent.org/ - -caps: - categorymappings: - - {id: 3, cat: PC/Mobile-Android, desc: "Android"} - - {id: 4, cat: PC/Mobile-Android, desc: " |-- Smartphone"} - - {id: 16, cat: Movies/HD, desc: "Bluray"} - - {id: 17, cat: Movies/HD, desc: " |-- 1080p"} - - {id: 18, cat: Movies/3D, desc: " |-- 3D"} - - {id: 19, cat: Movies/HD, desc: " |-- 720p"} - - {id: 20, cat: Movies/HD, desc: " |-- H.265 1080p"} - - {id: 21, cat: Movies/HD, desc: " |-- H.265 720p"} - - {id: 53, cat: Movies/BluRay, desc: " |-- Untouched"} - - {id: 30, cat: Movies/Other, desc: "Cartoons"} - - {id: 56, cat: Movies/Other, desc: " |-- Anime"} - - {id: 31, cat: Movies/Other, desc: " |-- Disney"} - - {id: 32, cat: Books/Comics, desc: " |-- Manga"} - - {id: 5, cat: Movies/WEB-DL, desc: "Cinema-News"} - - {id: 58, cat: Movies/SD, desc: " |-- Cinema News"} - - {id: 59, cat: Movies/WEB-DL, desc: " |-- WEBDL"} - - {id: 60, cat: Movies/WEB-DL, desc: " |-- WEBDL 1080p"} - - {id: 62, cat: Movies/WEB-DL, desc: " |-- WEBDL 2160p"} - - {id: 61, cat: Movies/WEB-DL, desc: " |-- WEBDL 720p"} - - {id: 22, cat: TV/Documentary, desc: "Documentari"} - - {id: 33, cat: Movies/DVD, desc: "DvD"} - - {id: 35, cat: Movies/DVD, desc: " |-- DvD5"} - - {id: 34, cat: Movies/DVD, desc: " |-- DvD9"} - - {id: 6, cat: Movies/SD, desc: "DvDRip"} - - {id: 8, cat: Movies/SD, desc: " |-- BDRip"} - - {id: 9, cat: Movies/SD, desc: " |-- Cult"} - - {id: 7, cat: Movies/SD, desc: " |-- DvDRip"} - - {id: 10, cat: Movies/SD, desc: " |-- DvDRip_Old"} - - {id: 11, cat: Movies/SD, desc: " |-- Film_mp4"} - - {id: 12, cat: Movies/Foreign, desc: " |-- Subbed"} - - {id: 52, cat: Movies/WEB-DL, desc: " |-- WebRip"} - - {id: 23, cat: Books, desc: "Edicola"} - - {id: 66, cat: Audio/Audiobook, desc: " |-- Audiobook"} - - {id: 25, cat: Books/EBook, desc: " |-- Ebook"} - - {id: 24, cat: Books/Mags, desc: " |-- Edicola"} - - {id: 54, cat: Audio/Lossless, desc: "Flac"} - - {id: 55, cat: Other/Misc, desc: "FutureCrew"} - - {id: 36, cat: Console, desc: "Games"} - - {id: 39, cat: Console/Other, desc: " |-- N.Switch"} - - {id: 40, cat: Console/NDS, desc: " |-- Nds"} - - {id: 41, cat: PC/Games, desc: " |-- Pc"} - - {id: 42, cat: Console/PS3, desc: " |-- Ps3"} - - {id: 43, cat: Console/PS4, desc: " |-- Ps4"} - - {id: 67, cat: Console/PS4, desc: " |-- Ps5"} - - {id: 44, cat: Console/Wii, desc: " |-- Wii"} - - {id: 45, cat: Console/WiiU, desc: " |-- Wii-U"} - - {id: 46, cat: Console/XBox, desc: " |-- Xbox"} - - {id: 13, cat: Audio, desc: "Musica"} - - {id: 14, cat: Audio/Other, desc: " |-- Compilation"} - - {id: 15, cat: Audio/MP3, desc: " |-- Mp3"} - - {id: 65, cat: Audio/Video, desc: " |-- Video Concerti"} - - {id: 26, cat: PC, desc: "Programmi Pc"} - - {id: 27, cat: PC/Mac, desc: " |-- Mac"} - - {id: 28, cat: PC/0day, desc: " |-- Windows"} - - {id: 47, cat: TV, desc: "Serie TV"} - - {id: 57, cat: TV/SD, desc: " |-- S.TV Complete"} - - {id: 48, cat: TV/HD, desc: " |-- Serie TV 1080p"} - - {id: 49, cat: TV/SD, desc: " |-- Serie TV 264p"} - - {id: 50, cat: TV/HD, desc: " |-- Serie TV 720p"} - - {id: 29, cat: Movies/UHD, desc: "UHD 4K"} - - {id: 51, cat: Other, desc: "Varie"} - - {id: 64, cat: Other, desc: " |-- Torrent Free"} - - {id: 63, cat: Other, desc: " |-- Video Corsi"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 40 on your account profile. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table:has(a[href*="login.php?error=1"]) - message: - text: "ERROR: Incorrect username!" - - selector: table:has(a[href*="login.php?error=4"]) - message: - text: "ERROR: Incorrect password!" - - selector: table:has(a[href*="login.php?error="]) - message: - text: "ERROR: Something went wrong during login!" - test: - path: index.php - selector: a[href*="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre (not used) - search_type: t_name - category: 0 # multi cat search not supported - # yes, no - include_dead_torrents: yes - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # does not return imdb link in results - - rows: - selector: table.sortable tbody tr:has(a[href*="/details.php?id="]) - - fields: - category: - selector: a[href*="/browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - title_default: - selector: a[href*="/details.php?id="] - title: - selector: div.tooltip-content > div - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/download.php?id="] - attribute: href - poster: - selector: div.tooltip-content > img - attribute: src - date: - selector: td:nth-child(2) > div:last-child - remove: span - filters: - - name: append - args: " +00:00" # auto adjusted by site account profile - - name: dateparse - args: "dd-MM-yyyy HH:mm zzz" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# TS Special Edition v.8.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fuzer.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fuzer.yml deleted file mode 100644 index d3fcd210..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/fuzer.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: fuzer -name: Fuzer -description: "Fuzer (FZ) is an ISRAELI Private Torrent Tracker for MOVIES / TV / GENERAL" -language: he-IL -type: private -encoding: windows-1255 -links: - - https://www.fuzer.xyz/ -legacylinks: - - https://fuzer.me/ - - https://www.fuzer.me/ - -caps: - categorymappings: - # Movies - - {id: 73, cat: Movies, desc: "FuzePacks -סרטים"} - - {id: 7, cat: Movies/SD, desc: "סרטים"} - - {id: 60, cat: Movies/SD, desc: "סרטים ישראליים"} - - {id: 9, cat: Movies/HD, desc: "סרטים HD"} - - {id: 101, cat: Movies/HD, desc: "סרטים x265"} - - {id: 59, cat: Movies/HD, desc: "סרטי BDRIP-BRRip"} - - {id: 61, cat: Movies/HD, desc: "סרטים ישראליים HD"} - - {id: 97, cat: Movies/UHD, desc: "סרטים UHD"} - - {id: 58, cat: Movies/DVD, desc: "סרטים DVD-R"} - - {id: 83, cat: Movies/Other, desc: "סרטים מדובבים"} - # TV - - {id: 76, cat: TV, desc: "FuzePacks - סדרות"} - - {id: 8, cat: TV/SD, desc: "סדרות"} - - {id: 62, cat: TV/SD, desc: "סדרות ישראליות"} - - {id: 10, cat: TV/HD, desc: "Tסדרות HD"} - - {id: 63, cat: TV/HD, desc: "סדרות ישראליות HD"} - - {id: 100, cat: TV/UHD, desc: "סדרות UHD"} - - {id: 84, cat: TV/Other, desc: "סדרות מדובבות"} - - {id: 65, cat: TV/Anime, desc: "אנימה"} - - {id: 64, cat: TV/Sport, desc: "ספורט"} - # PC - - {id: 74, cat: PC, desc: "FuzePacks - תוכנות"} - - {id: 15, cat: PC/0day, desc: "תוכנות PC"} - - {id: 11, cat: PC/Games, desc: "משחקים PC"} - - {id: 13, cat: PC/Mobile-Android, desc: "אפליקציות לאנדרואיד"} - - {id: 70, cat: PC/Mobile-iOS, desc: "אפליקציות לאייפון"} - - {id: 71, cat: PC/Mac, desc: "תוכנות MAC"} - # XXX - - {id: 16, cat: XXX, desc: "למבוגרים בלבד"} - # Games - - {id: 72, cat: Console, desc: "FuzePacks - משחקים"} - - {id: 55, cat: Console/XBox, desc: "משחקים XBOX"} - - {id: 12, cat: Console/PSP, desc: "משחקים PS"} - - {id: 56, cat: Console/Wii, desc: "משחקים WII"} - - {id: 57, cat: PC/Mobile-Other, desc: "משחקי קונסולות ניידות"} - # Music - - {id: 75, cat: Audio, desc: "FuzePacks - שירים"} - - {id: 14, cat: Audio, desc: "מוזיקה עולמית"} - - {id: 66, cat: Audio, desc: "מוזיקה ישראלית"} - - {id: 68, cat: Audio, desc: "פסקולים"} - - {id: 67, cat: Audio/Lossless, desc: "FLAC"} - # Books - - {id: 69, cat: Books, desc: "Ebooks"} - # Other - - {id: 17, cat: Other, desc: "שונות"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: dateadded - options: - dateadded: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="/login.php?do=logout&logouthash="] - -search: - paths: - - path: browse.php - inputs: - query: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - order: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # any (every word), phr (any match), all (exact match) - matchquery: any - freeleech: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - # on, off, only_dead - dead: on - $raw: "{{ range .Categories }}c[]={{.}}&{{end}}" - - rows: - # Others, Classic - selector: .box_torrent, table.tborder[align="center"][border="0"][cellpadding="0"][cellspacing="1"][class="tborder"][width="100%"] > tbody > tr:has(a[href^="/attachment.php?"]) - - fields: - category: - # Others, Classic - selector: a[href^="/browse.php?cat="], a[href^="/browse.php?r3=1&cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="/showthread.php?t="] - details: - selector: a[href^="/showthread.php?t="] - attribute: href - download: - selector: a[href^="/attachment.php?attachmentid="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: a[imgsrc] - attribute: imgsrc - size: - selector: td.inline_info:nth-last-child(4) - grabs: - selector: td.inline_info:nth-last-child(3) - filters: - - name: replace - args: [",", ""] - seeders: - selector: td.inline_info:nth-last-child(2) - filters: - - name: replace - args: [",", ""] - leechers: - selector: td.inline_info:nth-last-child(1) - filters: - - name: replace - args: [",", ""] - date: - selector: div[class^="up_info"] - filters: - - name: append - args: " +02:00" # IST - - name: re_replace - args: ["[^:]+: ", ""] - - name: dateparse - args: "dd/MM/yy HH:mm zzz" - description: - selector: div.sub_title - downloadvolumefactor: - case: - a[href*="freeleech=1"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/g3minitr4ck3r-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/g3minitr4ck3r-api.yml deleted file mode 100644 index b892ef7e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/g3minitr4ck3r-api.yml +++ /dev/null @@ -1,246 +0,0 @@ ---- -id: g3minitr4ck3r-api -name: G3MINI TR4CK3R (API) -description: "G3MINI TR4CK3R is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://gemini-tracker.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Films"} - - {id: 2, cat: TV, desc: "Séries"} - - {id: 10, cat: TV/Sport, desc: "Sports"} - - {id: 13, cat: Movies/Other, desc: "Documentaires - Film"} - - {id: 14, cat: TV/Documentary, desc: "Documentaires - Series"} - - {id: 15, cat: TV, desc: "Documentaires - Émission TV"} - - {id: 3, cat: PC/Games, desc: "Jeux PC"} - - {id: 7, cat: Movies, desc: "Films Animation Japonaise"} - - {id: 6, cat: TV/Anime, desc: "Animes"} - - {id: 8, cat: Books/Comics, desc: "Bandes dessinées"} - - {id: 11, cat: Books/Comics, desc: "Mangas"} - - {id: 9, cat: Books/Mags, desc: "Magazines"} - - {id: 12, cat: Books/EBook, desc: "Livres"} - - {id: 16, cat: Audio/Audiobook, desc: "Livres - Audios"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your G3MINI TR4CK3R account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: use_fl_tokens - type: checkbox - label: Use Freeleech Tokens automatically - default: true - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: vfq - type: checkbox - label: Replace VFQ with FRENCH - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title_phase1: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - title_vfq: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(VFQ)\\b", "FRENCH"] - title_phase2: - text: "{{ if .Config.vfq }}{{ .Result.title_vfq }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase3: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase3 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase3 }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - filters: - - name: append - args: "{{ if .Config.use_fl_tokens }}?use_token=1{{ else }}{{ end }}" - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.75 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.75 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gamestorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gamestorrents.yml deleted file mode 100644 index b984c47b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gamestorrents.yml +++ /dev/null @@ -1,89 +0,0 @@ ---- -id: gamestorrents -name: GamesTorrents -description: "GamesTorrents is a SPANISH Public tracker for GAMES" -language: es-ES -type: public -encoding: UTF-8 -links: - - https://www.gamestorrents.app/ -legacylinks: - - https://www.gamestorrents.com/ - - https://www.gamestorrents.tv/ - - https://www.gamestorrents.nu/ - - https://www.gamestorrents.fm/ - -caps: - categorymappings: - - {id: juegos-pc, cat: PC/Games, desc: "Games PC"} - - {id: juegos-psp, cat: Console/PSP, desc: "Games PSP"} - - {id: juegos-ps3, cat: Console/PS3, desc: "Games PS3"} - - {id: juegos-ps4, cat: Console/PS4, desc: "Games PS4"} - - {id: juegos-ps2, cat: Console/PS Vita, desc: "Games PS2"} - - {id: juegos-mac, cat: PC/Mac, desc: "Games Mac"} - - {id: juegos-xbox360, cat: Console/XBox 360, desc: "Games Xbox 360"} - - {id: juegos-wii, cat: Console/Wii, desc: "Games Wii"} - - {id: juegos-nds, cat: Console/NDS, desc: "Games NDS"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a#download_torrent - attribute: href - -search: - paths: - - path: / - allowEmptyInputs: true - inputs: - s: "{{ .Keywords }}" - - rows: - selector: table.metalion > tbody > tr - filters: - - name: andmatch - - fields: - title: - selector: td:nth-child(1) a - details: - selector: td:nth-child(1) a - attribute: href - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: split - args: ["/", 3] - download: - selector: td:nth-child(1) a - attribute: href - date: - selector: td:nth-child(2) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd-MM-yyyy zzz" - size: - selector: td:nth-child(3) - filters: - - name: replace - args: ["Undefined", "0 B"] - - name: replace - args: ["s", ""] - description: - selector: td:nth-child(4) - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gay-torrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gay-torrents.yml deleted file mode 100644 index 74c5b9db..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gay-torrents.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: gay-torrents -name: Gay-Torrents.net -description: "Gay-Torrents.net is a Private Torrent Tracker for GAY 3X" -language: en-US -type: semi-private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.gay-torrents.net/ - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 1: title - 4: size - 5: seeders - - name: order - type: select - label: Order requested from site - default: 0 - options: - 0: desc - 1: asc - - name: whisparrv2 - type: checkbox - label: Strip release date from queries (for Whisparr v2 compatibility) - default: false - -caps: - categorymappings: - - {id: porn, cat: XXX, desc: "Porn"} - - {id: porn/Amateur, cat: XXX, desc: "Amateur"} - - {id: porn/Anal, cat: XXX, desc: "Anal"} - - {id: porn/Asian, cat: XXX, desc: "Asian"} - - {id: porn/Bareback, cat: XXX, desc: "Bareback"} - - {id: porn/Bears, cat: XXX, desc: "Bears"} - - {id: porn/Bisexual, cat: XXX, desc: "Bisexual"} - - {id: porn/Black-Men, cat: XXX, desc: "Black-Men"} - - {id: porn/Chubs, cat: XXX, desc: "Chubs"} - - {id: porn/Clips, cat: XXX/Pack, desc: "Clips"} - - {id: porn/Cross-Generation, cat: XXX, desc: "Cross-Generation"} - - {id: porn/DVD-R, cat: XXX/DVD, desc: "DVD-R"} - - {id: porn/Fetish, cat: XXX, desc: "Fetish"} - - {id: porn/Group-Sex, cat: XXX, desc: "Group-Sex"} - - {id: porn/HD-Movies, cat: XXX, desc: "HD-Movies"} - - {id: porn/Hunks, cat: XXX, desc: "Hunks"} - - {id: porn/Images, cat: XXX/ImageSet, desc: "Images"} - - {id: porn/Interracial, cat: XXX, desc: "Interracial"} - - {id: porn/Jocks, cat: XXX, desc: "Jocks"} - - {id: porn/Latino, cat: XXX, desc: "Latino"} - - {id: porn/Mature, cat: XXX, desc: "Mature"} - - {id: porn/Member, cat: XXX, desc: "Member"} - - {id: porn/MiddleEast, cat: XXX, desc: "MiddleEast"} - - {id: porn/Military, cat: XXX, desc: "Military"} - - {id: porn/Muscle, cat: XXX, desc: "Muscle"} - - {id: porn/Oral-Sex, cat: XXX, desc: "Oral-Sex"} - - {id: porn/Solo, cat: XXX, desc: "Solo"} - - {id: porn/Transsexual, cat: XXX, desc: "Transsexual"} - - {id: porn/Twinks, cat: XXX, desc: "Twinks"} - - {id: porn/Vintage, cat: XXX, desc: "Vintage"} - - {id: porn/Wrestling, cat: XXX, desc: "Wrestling"} - - {id: nonporn, cat: Other, desc: "NonPorn"} - - {id: nonporn/Anime, cat: TV/Anime, desc: "Anime"} - - {id: nonporn/Applications, cat: PC, desc: "Applications"} - - {id: nonporn/Comedy, cat: Movies, desc: "Comedy"} - - {id: nonporn/Comics, cat: Books/Comics, desc: "Comics"} - - {id: nonporn/Coming-Out, cat: Movies, desc: "Coming-Out"} - - {id: nonporn/Documentary, cat: Movies, desc: "Documentary"} - - {id: nonporn/Drama, cat: Movies, desc: "Drama"} - - {id: nonporn/DVD-R, cat: Movies/DVD, desc: "DVD-R"} - - {id: nonporn/Gay-Movies, cat: Movies, desc: "Gay-Movies"} - - {id: nonporn/Misc, cat: Other/Misc, desc: "Misc"} - - {id: nonporn/Short-Film, cat: Movies, desc: "Short-Film"} - - {id: nonporn/Softcore, cat: Movies, desc: "Softcore"} - - {id: nonporn/Thriller, cat: Movies, desc: "Thriller"} - - {id: nonporn/TV-Episode, cat: TV, desc: "TV-Episode"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -login: - path: login.php - method: post - inputs: - vb_login_username: "{{ .Config.username }}" - vb_login_password: "{{ .Config.password }}" - cookieuser: 1 - do: login - error: - - selector: div.blockrow:contains("invalid") - test: - path: torrentslist.php - selector: ul.isuser - -search: - paths: - - path: "{{ if .Keywords }}search.php{{ else }}torrentslist.php{{ end }}" - inputs: - page: 1 - - path: "{{ if .Keywords }}search.php{{ else }}torrentslist.php{{ end }}" - inputs: - page: 2 - - path: "{{ if .Keywords }}search.php{{ else }}torrentslist.php{{ end }}" - inputs: - page: 3 - - path: "{{ if .Keywords }}search.php{{ else }}torrentslist.php{{ end }}" - inputs: - page: 4 - - path: "{{ if .Keywords }}search.php{{ else }}torrentslist.php{{ end }}" - inputs: - page: 5 - inputs: - $raw: "{{ range .Categories }}type={{.}}&{{end}}" - textsearch: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - order: "{{ .Config.order }}" - freeleech: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - keywordsfilters: - - name: re_replace - args: ["(\\d{2}\\.\\d{2}\\.\\d{2})", "{{ if .Config.whisparrv2 }}{{ else }}$1{{ end }}"] # strip release date from query for whisparr v2 compatibility - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - headers: - Referer: ["{{ .Config.sitelink }}"] - - rows: - selector: ul.TorrentList, ul.Torrent-List - - fields: - category: - selector: a[href^="torrentslist.php?type="] - attribute: href - filters: - - name: querystring - args: type - title: - selector: a[href^="torrentdetails.php?torrentid="] - filters: - - name: replace - args: ["[FFL] ", ""] # remove freeleech flag from title - download: - selector: a[href^="torrentdetails.php?torrentid="] - attribute: href - filters: - - name: append - args: "&do=download" - details: - selector: a[href^="torrentdetails.php?torrentid="] - attribute: href - size: - selector: .TorrentList3, .Torrent-List-Size - seeders: - selector: .TorrentList6, .Torrent-List-Seeds - leechers: - selector: .TorrentList7, .Torrent-List-Peers - date: - selector: .TorrentList8, .Torrent-List-Date - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "HH:mm dd-MMM-yyyy zzz" - downloadvolumefactor: - case: - "a:contains(\"[FFL]\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 -# Morbid Suite vB4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gay-torrentsorg.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gay-torrentsorg.yml deleted file mode 100644 index 016b62ac..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gay-torrentsorg.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: gay-torrentsorg -name: gay-torrents.org -description: "Gay-Torrents.org is a Private Torrent Tracker for GAY 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://gay-torrents.org/ - - https://gay-area.org/ - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_categories - type: info - label: "Hidden categories" - default: "Results for categories hidden in account profile will not be visible." - - name: info_results - type: info - label: "Search results" - default: "You can increase the number of search results in your profile.
Default is 15." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - seeds: seeders - size: size - filename: title - - name: order - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -caps: - categorymappings: - - {id: 15, cat: XXX, desc: "Amateur"} - - {id: 16, cat: XXX, desc: "Anal"} - - {id: 42, cat: XXX, desc: "Animation"} - - {id: 18, cat: XXX, desc: "Asian"} - - {id: 19, cat: XXX, desc: "Bareback"} - - {id: 20, cat: XXX, desc: "Bears"} - - {id: 22, cat: XXX, desc: "Bisexual"} - - {id: 21, cat: XXX, desc: "Black"} - - {id: 23, cat: XXX, desc: "Chubs"} - - {id: 25, cat: XXX, desc: "Cross Generation"} - - {id: 51, cat: XXX, desc: "Doctor/Medical"} - - {id: 71, cat: XXX, desc: "Fan Sites"} - - {id: 27, cat: XXX, desc: "Fetish"} - - {id: 28, cat: XXX, desc: "Group Sex"} - - {id: 30, cat: XXX, desc: "Hunks"} - - {id: 52, cat: XXX, desc: "Interracial"} - - {id: 68, cat: XXX, desc: "Homo Erotic"} - - {id: 68, cat: Movies, desc: "Homo Erotic"} - - {id: 68, cat: TV, desc: "Homo Erotic"} - - {id: 68, cat: Other, desc: "Homo Erotic"} - - {id: 32, cat: XXX, desc: "Latino"} - - {id: 50, cat: XXX, desc: "Middle Eastern"} - - {id: 33, cat: XXX, desc: "Military"} - - {id: 34, cat: XXX, desc: "Oral-Sex"} - - {id: 40, cat: Other, desc: "Other"} - - {id: 35, cat: XXX, desc: "Solo"} - - {id: 36, cat: XXX, desc: "Transsexual"} - - {id: 37, cat: XXX, desc: "Twinks"} - - {id: 38, cat: XXX, desc: "Vintage"} - - {id: 39, cat: XXX, desc: "Wrestling"} - - {id: 17, cat: PC, desc: "Applications"} - - {id: 31, cat: XXX/ImageSet, desc: "Images"} - - {id: 49, cat: Books, desc: "Books"} - - {id: 41, cat: Movies, desc: "Non-Porn"} - - {id: 41, cat: TV, desc: "Non-Porn"} - - {id: 41, cat: Other, desc: "Non-Porn"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -login: - method: post - path: login.php - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: div.fixed_width:nth-child(4) > span:contains("Incorrect") - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - # https://gay-torrents.org/torrents_beta.php?search=&active=0&options=0 - - path: torrents_beta.php - inputs: - $raw: "{{ range .Categories }}category[]={{.}}&{{end}}" - search: "{{ .Keywords }}" - # 0 all, 1 active, 2 dead, 3 free, 4 50%free, 5 25%free, 6 packs - active: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - # 0 title & tag, 1 title & descr - options: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.order }}" - - rows: - selector: div.torrent - - fields: - category: - selector: a[href^="torrents_beta.php?category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a.torrent_link - details: - selector: a.torrent_link - attribute: href - poster: - selector: a.screenshot - attribute: rel - download: - selector: a[href^="download.php?id="] - attribute: href - grabs: - selector: div.downloadTimes - filters: - - name: replace - args: ["No downloads yet", "0"] - size: - selector: div.size - seeders: - selector: div.downloadPeers div:nth-child(1) > a - leechers: - selector: div.downloadPeers > div:nth-child(2) > a - date: - selector: div.date - filters: - - name: re_replace - args: ["on (\\d{2}:\\d{2}) (\\d{2})-([a-zA-Z]{3})-(\\d{4})", "$2 $3 $4 $1"] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd MMM yyyy HH:mm zzz" - downloadvolumefactor: - case: - "div:nth-child(2) > div:nth-child(3) > a:contains(\"FREE!\")": 0 - "div:nth-child(2) > div:nth-child(3) > a:contains(\"-50%\")": 0.5 - "div:nth-child(2) > div:nth-child(3) > a:contains(\"-25%\")": 0.25 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 36 hours (as seconds = 36 x 60 x 60) - text: 129600 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gaytorrentru.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gaytorrentru.yml deleted file mode 100644 index bc1de0f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gaytorrentru.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -id: gaytorrentru -name: GAYtorrent.ru -description: "GayTorrent.ru is a Private Torrent Tracker for GAY 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.gaytor.rent/ -legacylinks: - - https://www.gaytorrent.ru/ - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_hidden - type: info - label: "Hidden categories" - default: "Results for categories hidden in account profile will not be visible." - - name: info_limit - type: info - label: "Download Ratio" - default: "GayTorrent.ru has a daily download slot limit as defined by your ratio, your uploads, your donations, etc. If downloads fail then you have exceeded your allocation. Access the web site to find more info here" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Unused accounts (both upload and download are zero) are disabled by the system after 60 days of no login. Used accounts with prior activity (upload and/or download) are disabled by the system after 240 days of no login." - -caps: - categorymappings: - - {id: 69, cat: XXX, desc: "AI"} - - {id: 62, cat: XXX, desc: "Amateur"} - - {id: 29, cat: XXX, desc: "Anal"} - - {id: 46, cat: XXX, desc: "Anime Games"} - - {id: 30, cat: XXX, desc: "Asian"} - - {id: 43, cat: XXX, desc: "Bareback"} - - {id: 19, cat: XXX, desc: "BDSM"} - - {id: 17, cat: XXX, desc: "Bears"} - - {id: 59, cat: XXX, desc: "Bisexual"} - - {id: 44, cat: XXX, desc: "Black"} - - {id: 50, cat: Books, desc: "Books & Magazines"} - - {id: 9, cat: XXX, desc: "Chubbies"} - - {id: 7, cat: XXX, desc: "Clips"} - - {id: 48, cat: Books/Comics, desc: "Comic & Yaoi"} - - {id: 5, cat: XXX, desc: "Daddies / Sons"} - - {id: 67, cat: XXX, desc: "Dildos"} - - {id: 66, cat: XXX, desc: "Fan Sites"} - - {id: 34, cat: XXX, desc: "Fetish"} - - {id: 68, cat: XXX, desc: "Fisting"} - - {id: 27, cat: XXX, desc: "Grey / Older"} - - {id: 32, cat: XXX, desc: "Group-Sex"} - - {id: 63, cat: XXX, desc: "Homemade"} - - {id: 12, cat: XXX, desc: "Hunks"} - - {id: 33, cat: XXX/ImageSet, desc: "Images"} - - {id: 53, cat: XXX, desc: "Interracial"} - - {id: 57, cat: XXX, desc: "Jocks"} - - {id: 35, cat: XXX, desc: "Latino"} - - {id: 36, cat: XXX, desc: "Mature"} - - {id: 58, cat: PC, desc: "Media Programs"} - - {id: 37, cat: XXX, desc: "Member"} - - {id: 54, cat: XXX, desc: "Middle Eastern"} - - {id: 38, cat: XXX, desc: "Military"} - - {id: 39, cat: XXX, desc: "Oral-Sex"} - - {id: 64, cat: XXX, desc: "Scat"} - - {id: 56, cat: XXX, desc: "Softcore"} - - {id: 40, cat: XXX, desc: "Solo"} - - {id: 61, cat: XXX, desc: "Straight older"} - - {id: 60, cat: XXX, desc: "Straight younger"} - - {id: 45, cat: Movies, desc: "Themed Movie"} - - {id: 47, cat: XXX, desc: "Trans"} - - {id: 70, cat: XXX, desc: "Trans/FTM"} - - {id: 1, cat: TV, desc: "TV / Episodes"} - - {id: 41, cat: XXX, desc: "Twinks"} - - {id: 42, cat: XXX, desc: "Vintage"} - - {id: 51, cat: XXX, desc: "Voyeur"} - - {id: 65, cat: XXX, desc: "Wrestling and Sports"} - - {id: 28, cat: XXX, desc: "Youngblood"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.panel:has(h3:contains("Login failed")) - test: - path: browse.php - selector: a[href$="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 both, 2 dead - incldead: 1 - # where to search: name, descr, filename, freeleech only, wishlist - # where to search: inname=1, indesc=1, infn=1, infl=1, inbook=1 - inname: 1 - infl: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - headers: - Referer: ["{{ .Config.sitelink }}browse.php"] - - rows: - selector: table.browse_result > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: .browsedesc > a - details: - selector: a[href^="details.php?id="] - attribute: href - _id: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: querystring - args: id - download: - # turn href="download.php/0738d444362fa610a08fb87b5c1bec798ca1731adbf3175b/GUARATORO.torrent - # into href="/download.php?id=0738d444362fa610a08fb87b5c1bec798ca1731adbf3175b&n=GUARATORO.torrent&p=dodownload - text: "download.php?id={{ .Result._id }}&n={{ .Result.title }}.torrent&p=dodownload" - poster: - selector: a[href^="details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - grabs: - selector: .tsnatch - files: - selector: .tfiles - size: - selector: .tsize - seeders: - optional: true - selector: a[href$="&toseeders=1"], span.red - leechers: - optional: true - selector: a[href$="&todlers=1"] - date: - selector: .tadded - # auto adjusted by site account profile - filters: - - name: re_replace - args: ["(\\d{4}-\\d{2}-\\d{2})(\\d{2}:\\d{2}:\\d{2}).*", "$1 $2"] - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss" - downloadvolumefactor: - case: - "td:nth-child(3) > div > nobr > font[color=\"yellow\"]": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/generationfree-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/generationfree-api.yml deleted file mode 100644 index aa468dd8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/generationfree-api.yml +++ /dev/null @@ -1,255 +0,0 @@ ---- -id: generationfree-api -name: Generation-Free (API) -description: "Generation-Free (GF-Free) is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://generation-free.org/ -legacylinks: - - https://generation-free.biz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Films"} - - {id: 2, cat: TV, desc: "Séries"} - - {id: 3, cat: Books, desc: "Ebook"} - - {id: 4, cat: Console, desc: "Jeux"} - - {id: 5, cat: PC, desc: "Logiciel"} - - {id: 6, cat: Audio, desc: "Musique"} - - {id: 8, cat: TV/Sport, desc: "Sport"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Generation-Free account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: vfq - type: checkbox - label: Replace VFQ with FRENCH - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive for 45 days are automatically deleted (all ranks included). To remain active, simply log in and participate (downloads, forums, shoutbox, games). Accounts created solely to build up numbers will be deactivated. As a free tracker with a limited number of spots, we regularly delete inactive accounts." - - name: page_for_test - type: select - label: Page for TEST - default: 1 - options: - 1: "1" - 2: "2" - 3: "3" - 4: "4" - 5: "5" - - name: info_page - type: info - label: About TEST page - default: "Generation-Free's API only delivers torrents that are at least 12 hours old. Therefore, when there are more than 100 torrents loaded to the site in a day, the indexer TEST may fail with 0 results. To allow the TEST to successed you will need to select another page to fetch." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - page: "{{ .Config.page_for_test }}" - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title_phase1: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - title_vfq: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(VFQ)\\b", "FRENCH"] - title_phase2: - text: "{{ if .Config.vfq }}{{ .Result.title_vfq }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase3: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase3 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase3 }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.5 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.5 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ggpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ggpt.yml deleted file mode 100644 index ef59cb47..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ggpt.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: ggpt -name: GGPT -description: "GGPT is a CHINESE Private Torrent Tracker for GAMES" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.gamegamept.com/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: PC/Games, desc: "PC游戏(PC Games)", default: true} - - {id: 404, cat: Console/PS4, desc: "索尼主机游戏(Sony console games)", default: true} - - {id: 405, cat: Console/XBox, desc: "微软主机游戏(Microsoft console games)", default: true} - - {id: 406, cat: Console/NDS, desc: "任天堂主机游戏(Nintendo console games)", default: true} - - {id: 407, cat: PC/Mac, desc: "苹果游戏(Apple Games)", default: true} - - {id: 409, cat: PC/Mobile-Android, desc: "安卓游戏(Android Games)", default: true} - - {id: 410, cat: Books/Mags, desc: "游戏书藉(Game Magazines)", default: true} - - {id: 412, cat: XXX/Other, desc: "9KG-PC", default: false} - - {id: 418, cat: XXX/Other, desc: "9KG-其他", default: false} - - modes: - search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Extreme User and above will be retained forever
  2. Elite User and above will not have their account deleted within 180 days after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 180 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 180 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 30 consecutive days.
" - -login: - # using the cookie method because site disables login occasionally (referred to as the silent state on the site) - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 404, 405, 406, 407, 409, 410] - - path: special.php - categories: [412, 418] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gigatorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gigatorrents.yml deleted file mode 100644 index f20bdc6c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gigatorrents.yml +++ /dev/null @@ -1,190 +0,0 @@ ---- -id: gigatorrents -name: GigaTorrents -description: "GigaTorrents is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://gigatorrents.ws/ - -caps: - categorymappings: - - {id: 91, cat: Movies/UHD, desc: "4K Blu-Ray"} - - {id: 62, cat: Movies/BluRay, desc: "Bluray Disc"} - - {id: 80, cat: Movies/SD, desc: "CAM/Eng"} - - {id: 79, cat: Movies/SD, desc: "CAM/Hun"} - - {id: 71, cat: Movies/DVD, desc: "DVD 9"} - - {id: 67, cat: Movies/DVD, desc: "DVD 9 Hun"} - - {id: 74, cat: Movies/HD, desc: "Film HD/Eng"} - - {id: 75, cat: Movies/HD, desc: "Film HD/Hun"} - - {id: 73, cat: Movies/SD, desc: "Film x264/Eng"} - - {id: 72, cat: Movies/SD, desc: "Film x264/Hun"} - - {id: 19, cat: Movies/SD, desc: "Film Xvid/Eng"} - - {id: 37, cat: Movies/SD, desc: "Film Xvid/Hun"} - - {id: 20, cat: Movies/DVD, desc: "Film/DVD-R"} - - {id: 45, cat: Movies/DVD, desc: "Film/DVD-R Hun"} - - {id: 47, cat: PC/Games, desc: "Játékok/ISO"} - - {id: 64, cat: PC/Games, desc: "Játékok/Rip"} - - {id: 48, cat: Other, desc: "Képek"} - - {id: 49, cat: XXX/ImageSet, desc: "Képek XXX"} - - {id: 51, cat: Audio/Video, desc: "Klip"} - - {id: 39, cat: Books, desc: "Könyvek/Eng"} - - {id: 52, cat: Books, desc: "Könyvek/Hun"} - - {id: 33, cat: Console, desc: "Konzol"} - - {id: 1, cat: Audio/Lossless, desc: "Lossless"} - - {id: 55, cat: PC/Mobile-Other, desc: "Mobil/PDA"} - - {id: 85, cat: TV/HD, desc: "Sorozat HD/Eng"} - - {id: 84, cat: TV/HD, desc: "Sorozat HD/Hun"} - - {id: 8, cat: TV/SD, desc: "Sorozat/SD-x264/Eng"} - - {id: 81, cat: TV/SD, desc: "Sorozat/SD-x264/Hun"} - - {id: 83, cat: TV/SD, desc: "Sorozat/SD-Xvid/Eng"} - - {id: 7, cat: TV/SD, desc: "Sorozat/SD-Xvid/Hun"} - - {id: 46, cat: XXX, desc: "XXX"} - - {id: 42, cat: Audio, desc: "Zene/Eng"} - - {id: 53, cat: Audio, desc: "Zene/Hun"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Rules for deletion:
  1. Users who have registered at least 4 weeks ago and have uploaded 0 bytes will be deleted
  2. Users who have not accessed the site for 1 year will be permanently deleted
  3. Users who have not accessed the site for 4 weeks will be deleted.
" - -login: - path: login.php - method: form - form: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.box:has(div.title:contains("bejelentkez")) - test: - path: index.php - selector: a[href^="logout.php"] - -search: - paths: - - path: torrentek.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 5 freeleech, 6 2x upload, 7 highlighted - incldead: "{{ if .Config.freeleech }}5{{ else }}1{{ end }}" - blah: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table#torrenttable > tbody > tr:has() - error: - - selector: div.content:contains("Meg van vonva a letöltési jogod") - - fields: - download: - selector: a[href^="/details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php?id=", "download.php?id="] - title: - selector: a[href^="/details.php?id="] - details: - selector: a[href^="/details.php?id="] - attribute: href - imdbid: - selector: a[href^="imdb.com/title/tt"] - attribute: href - poster: - selector: img.imdb-info - attribute: data-url - category: - selector: a[href^="/torrentek.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - seeders: - selector: td:nth-child(8) - filters: - - name: split - args: ["/", 0] - leechers: - selector: td:nth-child(8) - filters: - - name: split - args: ["/", 1] - grabs: - selector: td:nth-child(7) - files: - selector: td:nth-child(4) - description: - case: - img[src="pic/staff/yes.png"]: Verified - "*": Unverified - downloadvolumefactor: - selector: td:nth-child(6) > span:nth-last-child(2) - optional: true - default: 1 - filters: - - name: trim - args: x - uploadvolumefactor: - selector: td:nth-child(6) > span:nth-last-child(1) - optional: true - default: 1 - filters: - - name: trim - args: x - minimumratio: - text: 0.5 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - date: - selector: td:nth-child(2) - remove: a - filters: - - name: regexp - args: "Feltöltve:\\s*([\\d-]+\\s*[\\d:]+)" - size: - selector: td:nth-child(6) - remove: span -# xbtitFM v2.2.09 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gimmepeers.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gimmepeers.yml deleted file mode 100644 index 663f0112..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/gimmepeers.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: gimmepeers -name: GimmePeers -description: "GimmePeers (formerly ILT) is a Private PAY2DL Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: iso-8859-1 -links: - - https://www.gimmepeers.com/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 4, cat: PC/0day, desc: "App-WIN"} - - {id: 2, cat: PC/Mac, desc: "App-MAC"} - - {id: 27, cat: PC, desc: "App-LINUX"} - - {id: 29, cat: Audio/Audiobook, desc: "Books (a)"} - - {id: 5, cat: Books/EBook, desc: "Books (e)"} - - {id: 6, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 7, cat: Audio, desc: "Music"} - - {id: 28, cat: TV/Sport, desc: "Sports"} - - {id: 3, cat: Books/Other, desc: "Tutorials"} - - {id: 8, cat: Other, desc: "Other"} - - {id: 9, cat: Console/NDS, desc: "Game-NIN"} - - {id: 11, cat: Console/PS3, desc: "Game-PS"} - - {id: 10, cat: PC/Games, desc: "Game-WIN"} - - {id: 12, cat: Console/XBox, desc: "Game-XBOX"} - - {id: 13, cat: Movies/3D, desc: "Movie-3D"} - - {id: 26, cat: Movies/UHD, desc: "Movie-4K"} - - {id: 14, cat: Movies/BluRay, desc: "Movie-Bluray"} - - {id: 15, cat: Movies/DVD, desc: "Movie-DVDR"} - - {id: 16, cat: Movies/HD, desc: "Movie-x264"} - - {id: 17, cat: Movies/HD, desc: "Movie-x265"} - - {id: 19, cat: Movies/SD, desc: "Movie-XVID"} - - {id: 18, cat: Movies, desc: "Movie-Packs"} - - {id: 30, cat: TV/UHD, desc: "TV-4K"} - - {id: 22, cat: TV/HD, desc: "TV-x265"} - - {id: 20, cat: TV/HD, desc: "TV-x264-HD"} - - {id: 21, cat: TV/SD, desc: "TV-x264-SD"} - - {id: 25, cat: TV/HD, desc: "TV-Retail-HD"} - - {id: 24, cat: TV/SD, desc: "TV-Retail-SD"} - - {id: 23, cat: TV, desc: "TV-Packs"} - - {id: 50, cat: XXX/x264, desc: "xXx-HD"} - - {id: 49, cat: XXX/SD, desc: "xXx-SD"} - - modes: - search: [q] - tv-search: [q, season, ep, tvmazeid, genre] - movie-search: [q, imdbid, genre] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: /browse.php - error: - - selector: td:contains("Login failed!") - message: - selector: table tbody tr td font - test: - path: browse.php - selector: a[href="/logout.php"] - -search: - paths: - # https://www.gimmepeers.com/browse.php?search=2022&c13=1&blah=0&incldead=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if or .Query.IMDBID .Query.TVMazeID }}{{ or .Query.IMDBID .Query.TVMazeID }}{{ else }}{{ .Keywords }}{{ end }}{{ if .Query.Genre }} genre:{{ .Query.Genre }}{{ else }}{{ end }}" - # 0 title, 1 nfo, 2 filelist, 3 title+nfo, 4 imdb, 5 tvmaze - blah: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.TVMazeID }}5{{ else }}{{ end }}{{ if .Query.Genre }}3{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.TVMazeID .Query.Genre }}{{ else }}0{{ end }}" - # 0 active only, 1 dead only - incldead: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: "table.browsetable:not(:has(td.gp_sub_nav):contains(\"Top 25\")) tbody tr:has(a[href^=\"download.php\"]){{ if .Config.freeleech }}:has(img[src=\"/core/design/icons/fleech.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["(?i)\\s*{REQ}$", ""] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - filters: - - name: replace - args: ["/redir.php?url=", ""] - genre: - selector: span:has(a[title="search by genre"]) - filters: - - name: replace - args: ["votes", ""] - - name: re_replace - args: ["\\d+", ""] - description: - text: "{{ .Result.genre }}" - date: - selector: td:nth-child(7) nobr - filters: - - name: replace - args: ["\xA0", ""] - - name: append - args: " -04:00" # AST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(6) - files: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(10) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - img[src="/core/design/icons/fleech.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/girotorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/girotorrent.yml deleted file mode 100644 index 20f599d1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/girotorrent.yml +++ /dev/null @@ -1,254 +0,0 @@ ---- -id: girotorrent -name: Girotorrent -description: "Girotorrent is an ITALIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: private -encoding: UTF-8 -testlinktorrent: false -links: - - https://girotorrent.org/ -legacylinks: - - http://girotorrent.org/ # site forces https - -caps: - categorymappings: - # LIBREDICOLA - - {id: 13, cat: Books/Mags, desc: "Giornali e Riviste"} - - {id: 15, cat: Books/EBook, desc: "E-Book"} - - {id: 16, cat: Books/Comics, desc: "Fumetti"} - - {id: 70, cat: Books, desc: "Manuali e Guide"} - - {id: 72, cat: Audio/Audiobook, desc: "Audio Libri"} - # CINEMA - - {id: 17, cat: Movies/Other, desc: "Movie Cam-Ts"} - - {id: 18, cat: Movies/Other, desc: "Movie Screener"} - - {id: 61, cat: Movies/Other, desc: "Movie R5-R6"} - - {id: 19, cat: Movies/Other, desc: "Movie DVDRip"} - - {id: 20, cat: Movies/Other, desc: "Movie BDRip"} - - {id: 60, cat: Movies/Other, desc: "Movie BluRay"} - - {id: 63, cat: Movies/Other, desc: "Movie WEBDLRip"} - # VIDEOTECA - - {id: 22, cat: Movies/SD, desc: "Movie BDRip"} - - {id: 23, cat: Movies/SD, desc: "Movie DvdRip"} - - {id: 95, cat: Movies/SD, desc: "Movie WEBRip"} - - {id: 98, cat: Movies/WEB-DL, desc: "Movie WEBDL"} - - {id: 24, cat: Movies/DVD, desc: "Movie DVD-R 5"} - - {id: 25, cat: Movies/DVD, desc: "Movie DVD-R 9"} - - {id: 26, cat: Movies/HD, desc: "Movie Blu-Ray HD"} - - {id: 27, cat: Movies/UHD, desc: "Movie 4K Ultra HD"} - - {id: 96, cat: Movies/HD, desc: "Movie HD Hevc"} - - {id: 28, cat: Movies/Foreign, desc: "Movie Subbed-ita"} - - {id: 73, cat: Movies/SD, desc: "Movie MP4"} - - {id: 29, cat: Movies/Foreign, desc: "Movie Lingua Originale"} - # TELEVISIONE - - {id: 36, cat: TV, desc: "TV Serie"} - - {id: 99, cat: TV/HD, desc: "TV Serie Hevc"} - - {id: 37, cat: TV, desc: "TV Film"} - - {id: 59, cat: TV/Sport, desc: "TV Sport"} - - {id: 77, cat: TV, desc: "TV Reality"} - - {id: 38, cat: TV, desc: "TV Concerti-Spettacoli"} - - {id: 39, cat: TV, desc: "TV Teatro-Cabaret"} - - {id: 40, cat: TV/Documentary, desc: "Tv Documentario"} - # ANIMAZIONE - - {id: 32, cat: TV/Anime, desc: "Anime Disney"} - - {id: 33, cat: TV/Anime, desc: "Anime"} - - {id: 34, cat: TV/Anime, desc: "Anime Altri Cartoni"} - - {id: 62, cat: TV/Anime, desc: "Anime Natale Bimbi Girotor"} - # MUSICA - - {id: 42, cat: Audio, desc: "Musica CD Singoli"} - - {id: 43, cat: Audio, desc: "Musica Italiana"} - - {id: 44, cat: Audio, desc: "Musica Straniera"} - - {id: 45, cat: Audio, desc: "Musica Compilation"} - - {id: 46, cat: Audio/Video, desc: "Musica Video Clip"} - - {id: 58, cat: Audio, desc: "Musica Discografie"} - - {id: 97, cat: Audio/Lossless, desc: "Musica Flac"} - # SALA GIOCHI - - {id: 47, cat: PC/Games, desc: "Giochi PC"} - - {id: 48, cat: Console/PS3, desc: "Giochi PS2-PS3"} - - {id: 49, cat: Console/Wii, desc: "Giochi Nintendo Wii"} - - {id: 50, cat: Console/XBox, desc: "Giochi Xbox"} - - {id: 51, cat: Console/PSP, desc: "Giochi PSP"} - - {id: 52, cat: Console/3DS, desc: "Giochi Nintendo DS-DS3"} - # SOFTWARE - - {id: 54, cat: PC, desc: "Programmi Windows"} - - {id: 55, cat: PC/Mac, desc: "Programmi Mac"} - - {id: 69, cat: PC, desc: "Programmi Portable"} - - {id: 56, cat: PC, desc: "Programmi Linux"} - # CELLULARI-PALMARI - - {id: 68, cat: PC/Mobile-iOS, desc: "IPhone-IPad"} - - {id: 71, cat: PC/Mobile-Android, desc: "Android"} - - {id: 74, cat: Other, desc: "Varie"} - - {id: 75, cat: Other, desc: "Immagini Wallpaper"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. The default is 15. - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: div.error - - selector: span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -download: - before: - path: thanks.php - method: post - inputs: - infohash: "{{ .DownloadUri.Query.id }}" - thanks: 1 - rndval: "1487013827343" - selectors: - - selector: a[href^="download.php?id="] - attribute: href - filters: - - name: regexp - args: "^(.+?)&key=" - -search: - paths: - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ range .Categories }}{{.}};{{end}}" - # 0 filename, 1 file&descr, 2 descr, 3 uploader, 5 gold, 6 silver, 7 bronze - # note the options are exclusive, so searching for gold means the search keywords are ignored - options: 0 - # 0 all, 1 activeonly, 2 deadonly - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to 1 - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace # S01E01 to 1 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1 $2"] - # does not support imdbid search and does not return imdb link in results - - rows: - selector: "div.b-content > table > tbody > tr > td > table.lista > tbody > tr:has(a[href^=\"index.php?page=torrent-details&id=\"]){{ if .Config.freeleech }}:has(img[alt=\"Gold 100% Free\"]){{ else }}{{ end }}, div.b-content > table > tbody > tr > td > table.lista > tbody > tr:has(a[href^=\"index.php?page=torrent-details&id=\"]){{ if .Config.freeleech }}:has(img[alt=\"Free Leech\"]){{ else }}{{ end }}" - filters: - - name: andmatch - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[onmouseover][href^="index.php?page=torrent-details&id="] - filters: - - name: re_replace # replace special characters with " " (space) - args: ["[\\[!\"#$%&'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]", " "] - - name: diacritics - args: replace - - name: re_replace # replace multiple spaces - args: ["[ ]{2,}", " "] - # normalize to SXXEYY format - - name: re_replace # S01 E01 to S01E01 - args: ["(?i)\\bS(\\d+)\\sE(\\d+)\\b", "S$1E$2"] - - name: re_replace # 01x01 to S01E01 - args: ["(?i)(\\d{2})x(\\d+)", "S$1E$2"] - - name: re_replace # 1x01 to S01E01 - args: ["(?i)\\b(\\d{1})x(\\d+)", "S0$1E$2"] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - - name: re_replace # Episodio 4 to E4 - args: ["(?i)\\b(?:[\\/\\|]?Episodio\\s?(\\d+)|Puntata\\s?(\\d+))", "E$1$2"] - - name: re_replace # Episodi 4 5 to E04-05 - args: ["(?i)\\b(?:Puntate\\s*)(\\d+)\\s?(\\d+)", "E0$1-0$2"] - - name: re_replace # rimozioni varie - args: ["(?i)(Serie completa|Completat?a?|in pausa)", ""] - details: - selector: a[onmouseover][href^="index.php?page=torrent-details&id="] - attribute: href - download: - selector: a[href^="index.php?page=downloadcheck"] - attribute: href - poster: - selector: a[onmouseover][href^="index.php?page=torrent-details&id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - date: - selector: td:nth-last-child(9) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd/MM/yyyy" - seeders: - selector: td:nth-last-child(8) - leechers: - selector: td:nth-last-child(7) - grabs: - selector: td:nth-last-child(6) - size: - selector: td:nth-last-child(4) - downloadvolumefactor: - case: - img[alt="Free Leech"]: 0 - img[alt="Gold 100% Free"]: 0 - img[alt="Silver 50% Free"]: 0.5 - img[alt="Bronze 25% Free"]: 0.75 - "*": 1 - uploadvolumefactor: - selector: img[alt$="x Upload Multiplier"] - attribute: alt - optional: true - default: 1 - filters: - - name: replace - args: ["x Upload Multiplier", ""] - minimumratio: - text: 0.5 -# xbtitFM v4.2.12 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/haidan.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/haidan.yml deleted file mode 100644 index 33ca556e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/haidan.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -id: haidan -name: HaiDan -description: "HaiDan is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.haidan.video/ -legacylinks: - - https://www.haidan.cc/ - -caps: - categorymappings: - - {id: 404, cat: TV/Documentary, desc: "Documentaries(纪录片)"} - - {id: 401, cat: Movies, desc: "Movies(电影)"} - - {id: 405, cat: TV/Anime, desc: "Animations(动画片)"} - - {id: 402, cat: TV, desc: "TV Series(电视剧)"} - - {id: 403, cat: TV, desc: "TV Shows(综艺)"} - - {id: 406, cat: Audio/Video, desc: "Music Videos(MV)"} - - {id: 407, cat: TV/Sport, desc: "Sports(体育)"} - - {id: 409, cat: Other, desc: "Misc(其他)"} - - {id: 408, cat: Audio, desc: "HQ Audio(音乐)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_indexer - type: info - label: Alert - default: HaiDan has switched to a group torrent list, which means this Indexer can only display the first item in each group.
This yml Indexer will eventually be re-written in c# as soon as a c# dev volunteers, to display all items in a group. - - name: info_flaresolverr - type: info_flaresolverr - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users with no traffic (ie, upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if .Query.DoubanID }}/{{ .Query.DoubanID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: div.group_content:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - year: - optional: true - selector: div.video_year - filters: - - name: replace - args: ["年份:", " "] - title: - selector: a.video_name_str - filters: - - name: append - args: "{{ .Result.year }}" - details: - selector: a.video_name_str - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - description: - selector: div.torrent_name_col > a - poster: - selector: img#poster - attribute: data-src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - selector: a[href*="movie.douban.com/subject/"] - attribute: href - size: - # group size. item size - selector: div.group > div.video_size, div.torrent_item > div.video_size - seeders: - selector: div.group > div.seeder_col, div.torrent_item > div.seeder_col - leechers: - selector: div.group > div.leecher_col, div.torrent_item > div.leecher_col - grabs: - selector: div.group > div.snatched_col, div.torrent_item > div.snatched_col - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - date_elapsed: - # time type: time elapsed (default) - selector: div.time_col > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: div.time_col:contains("发布于"):not(:has(span[title])) - optional: true - remove: span - filters: - - name: replace # remove the 'posted on' tag - args: ["发布于", ""] - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - minimumratio: - text: 1.0 -# NexusPHP Standard v1.5 Beta 4 customised diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/haitang.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/haitang.yml deleted file mode 100644 index f003cb2a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/haitang.yml +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: haitang -name: Hǎitáng (海棠PT) -description: "Hǎitáng (海棠PT) is a CHINESE Private Torrent Tracker for OPERA / CROSSTALK" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.htpt.cc/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 1, cat: Audio, desc: "相声 Crosstalk", default: true} - - {id: 4091, cat: Audio, desc: "评书 Storytelling", default: true} - - {id: 4097, cat: Audio, desc: "戏曲 Opera", default: true} - - {id: 4098, cat: Audio, desc: "鼓/琴 Drum / Piano", default: true} - - {id: 4099, cat: Audio, desc: "小曲 Small Song", default: true} - - {id: 4101, cat: Audio, desc: "小品 Sketch", default: true} - - {id: 4104, cat: Audio, desc: "二人转 Duets", default: true} - - {id: 4105, cat: TV, desc: "小剧种 Puppet shows", default: true} - - {id: 4103, cat: Audio/Audiobook, desc: "广播剧 Audiobooks", default: false} - - modes: - search: [q] - tv-search: [q, season, ep] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Users with Veteran User (Wen Chou) and above will be retained forever
  2. Elite User (Wu Sheng) and above will not be deleted after parking (in the control panel)
  3. Users with a parked account who do not log in for 365 consecutive days will be deleted
  4. Users with a non-parked account who have not logged in for 180 consecutive days be deleted
  5. Users who have no traffic (i.e. upload/download The data is all 0) will be deleted If they do not log in for 90 consecutive days.
" - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [1, 4091, 4097, 4098, 4099, 4101, 4104, 4105] - - path: live.php - categories: [4103] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 onlyactive, 2 onlydead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.1 2021-10-15 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/happyfappy.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/happyfappy.yml deleted file mode 100644 index 6b9eb072..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/happyfappy.yml +++ /dev/null @@ -1,163 +0,0 @@ ---- -id: happyfappy -name: HappyFappy -description: "HappyFappy (HF) is a Private Torrent Tracker for 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.happyfappy.net/ -legacylinks: - - https://www.happyfappy.org/ - -caps: - categorymappings: - - {id: 15, cat: XXX, desc: "AI"} - - {id: 11, cat: XXX, desc: "Asian"} - - {id: 6, cat: XXX, desc: "Fansite"} - - {id: 13, cat: XXX, desc: "Games"} - - {id: 3, cat: XXX, desc: "Gay / Bi"} - - {id: 4, cat: XXX, desc: "Interracial"} - - {id: 5, cat: XXX, desc: "Lesbian"} - - {id: 9, cat: XXX, desc: "Packs"} - - {id: 10, cat: XXX, desc: "Pics"} - - {id: 1, cat: XXX, desc: "Pron"} - - {id: 8, cat: XXX, desc: "Retro"} - - {id: 14, cat: XXX, desc: "Scat"} - - {id: 12, cat: XXX, desc: "Transsexual"} - - {id: 7, cat: XXX, desc: "VR"} - - modes: - search: [q] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the HappyFappy2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To keep your account active, sign in and browse the site at least once every 120 days. Seeding torrents does not count as account activity, so in order to remain active you need to sign in and browse the site. Pornstar Trainee (and above) have a longer inactivity timer, but logging in regularly is recommended to learn about special events and new features." - -login: - path: login - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cinfo: "2550|1350|24|-60" - iplocked: 0 - keeploggedin: 1 - error: - - selector: div.error - test: - path: / - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - title: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - category: - selector: a[href^="/torrents.php?filter_cat["] - attribute: href - filters: - - name: regexp - args: \[(\d+)\] - title: - selector: a[href^="/torrents.php?id="] - details: - selector: a[href^="/torrents.php?id="] - attribute: href - download: - selector: a[href^="/torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/happyfappy2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/happyfappy2fa.yml deleted file mode 100644 index e7bd1df8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/happyfappy2fa.yml +++ /dev/null @@ -1,152 +0,0 @@ ---- -id: happyfappy2fa -name: HappyFappy2FA -description: "HappyFappy (HF) is a Private Torrent Tracker for 3X. This indexer uses cookie login for 2FA." -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.happyfappy.net/ -legacylinks: - - https://www.happyfappy.org/ - -caps: - categorymappings: - - {id: 15, cat: XXX, desc: "AI"} - - {id: 11, cat: XXX, desc: "Asian"} - - {id: 6, cat: XXX, desc: "Fansite"} - - {id: 13, cat: XXX, desc: "Games"} - - {id: 3, cat: XXX, desc: "Gay / Bi"} - - {id: 4, cat: XXX, desc: "Interracial"} - - {id: 5, cat: XXX, desc: "Lesbian"} - - {id: 9, cat: XXX, desc: "Packs"} - - {id: 10, cat: XXX, desc: "Pics"} - - {id: 1, cat: XXX, desc: "Pron"} - - {id: 8, cat: XXX, desc: "Retro"} - - {id: 14, cat: XXX, desc: "Scat"} - - {id: 12, cat: XXX, desc: "Transsexual"} - - {id: 7, cat: XXX, desc: "VR"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To keep your account active, sign in and browse the site at least once every 120 days. Seeding torrents does not count as account activity, so in order to remain active you need to sign in and browse the site. Pornstar Trainee (and above) have a longer inactivity timer, but logging in regularly is recommended to learn about special events and new features." - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: "#nav_userinfo" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - title: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - category: - selector: a[href^="/torrents.php?filter_cat["] - attribute: href - filters: - - name: regexp - args: \[(\d+)\] - title: - selector: a[href^="/torrents.php?id="] - details: - selector: a[href^="/torrents.php?id="] - attribute: href - download: - selector: a[href^="/torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hawke-uno.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hawke-uno.yml deleted file mode 100644 index f332eb00..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hawke-uno.yml +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: hawke-uno -name: hawke-uno -description: "hawke-uno (HUNO) is a Private Torrent Tracker for HD MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://hawke.uno/ -legacylinks: - - https://www.hawke.uno/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your hawke-uno account My Security page and clicking on the API Token tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "SS and IF members must either login or register any seeding activity in a rolling window of 180 days to retain their account. Failure to do so will result in their account being disabled by the site automatically." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - categorydesc: - selector: category - title: - selector: name - filters: - - name: re_replace - args: ["\\(((?>[^()]+|(?)\\(|(?<-o>)\\))*(?(o)(?!)))(?Torrents per page: setting to 100 on your account profile. - -login: - method: post - path: index.php?page=login - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: td.lista span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://freecztorrent.eu/index.php?page=torrents&active=0&category=26;29&order=3&by=2 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}" - # 0 all, 1 active only, 2 dead only - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.benylista > tbody > tr:has(a[href^="download.php"]):not(:has(a[href$="category=0"])) - - fields: - category: - selector: a[href*="category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details"] - details: - selector: a[href^="index.php?page=torrent-details"] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - poster: - selector: img.beny_img - attribute: src - date: - # 07.09.2023 06:20:35 - selector: td:nth-child(8) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss" - size: - selector: td:nth-child(9) - seeders: - selector: td:nth-child(10) - leechers: - selector: td:nth-child(11) - grabs: - selector: td:nth-child(10) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# xbtit v2.3.1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hd-unit3d-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hd-unit3d-api.yml deleted file mode 100644 index 55146061..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hd-unit3d-api.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: hd-unit3d-api -name: HD-UNiT3D (API) -description: "HD-UNiT3D is a Private Torrent Tracker for HD MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://hd-united.vn/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Audio"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your HD-UNiT3D account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts will be deleted as scheduled by Staff." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.1.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdarea.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdarea.yml deleted file mode 100644 index ee312362..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdarea.yml +++ /dev/null @@ -1,184 +0,0 @@ ---- -id: hdarea -name: HDArea -description: "HDArea (HDA) is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdarea.club/ -legacylinks: - - https://www.hdarea.co/ - - https://www.hdarea.club/ - -caps: - categorymappings: - - {id: 300, cat: Movies/UHD, desc: "Movies UHD-4K"} - - {id: 401, cat: Movies/BluRay, desc: "Movies Blu-ray"} - - {id: 415, cat: Movies/HD, desc: "Movies REMUX"} - - {id: 416, cat: Movies/3D, desc: "Movies 3D"} - - {id: 410, cat: Movies/HD, desc: "Movies 1080p"} - - {id: 411, cat: Movies/HD, desc: "Movies 720p"} - - {id: 414, cat: Movies/DVD, desc: "Movies DVD"} - - {id: 412, cat: Movies/WEB-DL, desc: "Movies WEB-DL"} - - {id: 413, cat: Movies/HD, desc: "Movies HDTV"} - - {id: 417, cat: Movies/Other, desc: "Movies iPad"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries"} - - {id: 405, cat: TV/Anime, desc: "Animations"} - - {id: 402, cat: TV, desc: "TV Series"} - - {id: 403, cat: TV, desc: "TV Shows"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - {id: 407, cat: TV/Sport, desc: "Sports"} - - {id: 409, cat: Other, desc: "Misc"} - - {id: 408, cat: Audio, desc: "HQ Audio"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account Retention Rules:
  1. Veteran Users and above will be retained forever
  2. Insane User and above will not be deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 150 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 60 consecutive days.
  5. Newly registered users who have no traffic for 7 days (i.e., upload/download data is 0) will be deleted.
" - -login: - # switched from the form method to the cookie method due to #9577 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="usercp.php"] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if or .Query.IMDBID .Query.DoubanID }}{{ or .Query.IMDBID .Query.DoubanID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 onlydead - incldead: 1 - # 0 all, 1 normal, 2 free, 2 2x, 4 2xFree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # uses 'smart search' - search_area: 0 - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-last-child(6) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-last-child(6):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-last-child(5) - seeders: - selector: td.rowfollow:nth-last-child(4) - leechers: - selector: td.rowfollow:nth-last-child(3) - grabs: - selector: td.rowfollow:nth-last-child(2) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.8 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 (customised) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdbao.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdbao.yml deleted file mode 100644 index b0c0d76c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdbao.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: hdbao -name: HDBao -description: "HDBao is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdbao.cc/ - -caps: - categorymappings: - - {id: 407, cat: TV/Sport, desc: "Sports/赛事"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 409, cat: Audio, desc: "Misc/音乐"} - - {id: 408, cat: Audio/Video, desc: "Concert/演唱会"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐MV"} - - {id: 405, cat: TV/Other, desc: "Animations/动漫&动画"} - - {id: 410, cat: Books/Technical, desc: "Tutorial/教程"} - - {id: 411, cat: Other, desc: "Others/其他"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because of #15893 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0: include dead, 1: alive, 2: dead - incldead: 0 - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0: title, 1: description, 3: uploader, 4: imdburl (4 does not appear to work) - search_area: 0 - # 0: AND, 2: exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # Support in NexusPHP so bots don't pollute trending searches: - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - # Users can change the "Time display type" in their tracker preferences: - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td:nth-child(6) - optional: true - default: 0 - leechers: - selector: td:nth-child(7) - optional: true - default: 0 - grabs: - selector: td:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdc.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdc.yml deleted file mode 100644 index de652adb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdc.yml +++ /dev/null @@ -1,174 +0,0 @@ ---- -id: hdc -name: HDCiTY -description: "HDCiTY (HDC) is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdcity.city/ - - https://hdcity.work/ - - https://hdcity.leniter.org/ - - https://hdcity4.leniter.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "Series/剧集"} - - {id: 404, cat: TV/Documentary, desc: "Doc/档案记录"} - - {id: 405, cat: TV/Anime, desc: "Anim/动漫"} - - {id: 403, cat: TV, desc: "Shows/节目"} - - {id: 406, cat: Audio/Video, desc: "MV/音乐视频"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 408, cat: Audio, desc: "Audio/音频"} - - {id: 727, cat: XXX, desc: "XXX/家长指引"} - - {id: 728, cat: Other, desc: "Edu/文档/教材"} - - {id: 729, cat: PC, desc: "Soft/软件"} - - {id: 409, cat: Other, desc: "Other/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_Results - type: info - label: Settings for Results - default: "To use this indexer please set the following on your HDC account profile:
  • Site Language: English
  • Torrent List Profile: Default Modern [preview]
  • Time Type: Time Added
  • Torrents per page: 100
" - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 180 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 60 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 30 days in a row.
" - -login: - path: login - method: form - form: form[action="signinhandler"] - inputs: - nftsidl1c03b: "{{ .Config.username }}" - insf0urO0abl1i: "{{ .Config.password }}" - selectorinputs: - s0hot: - selector: input[name="s0hot"] - attribute: value - asDf0z: - selector: input[name="asDf0z"] - attribute: value - error: - - selector: div.errormsg - test: - path: / - selector: a[href^="logout?key="] - -search: - paths: - # https://hdcity.city/pt?incldead=0&spstate=0&inclbookmarked=0&iwannaseethis=shanghai+triad&search_area=0&search_mode=0 - # https://hdcity.city/pt?incldead=0&spstate=0&inclbookmarked=0&iwannaseethis=115012&search_area=4&search_mode=0 - - path: pt - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - iwannaseethis: "{{ if .Query.IMDBID }}{{ .Query.IMDBIDShort }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdb number - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: div.mitem - - fields: - category: - selector: div.mcontent - attribute: style - filters: - - name: regexp - args: "\\/(\\d+).png" - title: - selector: div.mtop a[href^="t-"] - details: - selector: div.mtop a[href^="t-"] - attribute: href - download: - selector: a[href^="download?id="] - attribute: href - poster: - selector: div.mcontent:not(:has(img[src$="poster.jpg"])) img - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: div.mbottom div:nth-last-child(7) - remove: i - seeders: - selector: a[href$="#seeders"] - optional: true - default: 0 - leechers: - selector: a[href$="#leechers"] - optional: true - default: 0 - grabs: - selector: a[href^="viewsnatches?id="] - optional: true - default: 0 - date: - selector: div[style="minfo"] - remove: span - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - case: - div.bfree: 0 - div.btwoupfree: 0 - div.bhalfdown: 0.5 - div.btwouphalfdown: 0.5 - div.bthirdown: 0.3 - div.bnone: 1 - "*": 1 - uploadvolumefactor: - case: - div.btwouphalfdown: 2 - div.btwoupfree: 2 - div.btwoup: 2 - "*": 1 - minimumratio: - text: 1 -# engine n/a (based on NexusPHP) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdclone.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdclone.yml deleted file mode 100644 index 062430b3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdclone.yml +++ /dev/null @@ -1,211 +0,0 @@ ---- -id: hdclone -name: HDClone -description: "HDClone is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.hdclone.top/ -legacylinks: - - https://pt.hdclone.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫、动画"} - - {id: 409, cat: Audio/Video, desc: "Playlet/短剧"} - - {id: 410, cat: Audio/Video, desc: "MV/演唱会"} - - {id: 408, cat: Audio, desc: "Music/音乐"} - - {id: 407, cat: Other, desc: "Others/其他(慎选)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the HDClone Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 80 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 60 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 10 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.11 2024-03-27 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdcztorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdcztorrent.yml deleted file mode 100644 index 55b1e525..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdcztorrent.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: hdcztorrent -name: HD-CzTorrent -description: "HD-CzTorrent is a CZECH Semi-Private PAY2DL site for MOVIES / TV / GENERAL" -language: cs-CZ -type: semi-private -encoding: UTF-8 -links: - - http://www.hd-cztorrent.cz/ -legacylinks: - - https://www.hd-cztorrent.cz/ # site is forcing http - -caps: - categorymappings: - # FILMY - - {id: 117, cat: Movies/DVD, desc: "DvD"} - - {id: 119, cat: Movies/BluRay, desc: "Blu-Ray"} - - {id: 120, cat: Movies/HD, desc: "HD 720"} - - {id: 121, cat: Movies/HD, desc: "HD 1080"} - - {id: 125, cat: Movies/3D, desc: "3D Filmy"} - - {id: 126, cat: Movies/SD, desc: "SD Filmy"} - - {id: 127, cat: Movies/HD, desc: "H-265"} - - {id: 128, cat: TV/Documentary, desc: "Dokument"} - - {id: 129, cat: TV/Sport, desc: "Sportovní Filmy"} - - {id: 143, cat: Movies/Foreign, desc: "film titulky"} - - {id: 144, cat: TV/Foreign, desc: "serial titulky"} - - {id: 145, cat: Movies/Foreign, desc: "film original"} - - {id: 146, cat: TV/Foreign, desc: "serial original"} - - {id: 47, cat: TV/Other, desc: "Animované"} - - {id: 77, cat: Movies/UHD, desc: "ULTRA HD 4K"} - - {id: 45, cat: TV, desc: "SERIALY CZ"} - # premium - - {id: 130, cat: Movies/WEB-DL, desc: "TV - WEB- DL HBO Max"} - - {id: 133, cat: TV/WEB-DL, desc: "TV - WEB- DL Disney"} - - {id: 134, cat: TV/WEB-DL, desc: "TV -WEB- DL NETLIFIX"} - - {id: 135, cat: TV/WEB-DL, desc: "TV - WEB- DL Prime"} - - {id: 136, cat: TV/WEB-DL, desc: "TV -WEB- DL Skyshow"} - - {id: 147, cat: TV/WEB-DL, desc: "marvel"} - - {id: 148, cat: TV/WEB-DL, desc: "VOYO"} - # rls track - - {id: 131, cat: Movies, desc: "RLS For HDCZT"} - - {id: 132, cat: Movies, desc: "Film na přání"} - # programy win linux - - {id: 62, cat: PC, desc: "Aplikace All"} - # knihy foto pdf - - {id: 64, cat: Books, desc: "knihy-foto"} - - {id: 65, cat: Audio/Audiobook, desc: "Mluvene slovo"} - # porno - - {id: 11, cat: XXX, desc: "XXX 18+KLASIKA"} - - {id: 106, cat: XXX/ImageSet, desc: "XXX-OBRÁZKY"} - # Hudba mp3-flac - - {id: 58, cat: Audio, desc: "Hudba"} - # HRY - - {id: 73, cat: PC/Games, desc: "Hry, Games"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - method: post - path: index.php?page=login - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: td.lista span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # http://www.hd-cztorrent.cz/index.php?page=torrents&search=the+avengers&category=0&uploader=0&options=0&active=0&gold=0&order=3&by=2 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}" - uploader: 0 - # 0 filename, 1 file & desc, 2 description - options: 0 - # 0 all, 1 active only, 2 dead only - active: 0 - # 0 all, 1 classic, 2 silver, 3 gold, 4 silver & gold - gold: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: td table.lista > tbody > tr:has(a[href^="download.php"]):has(a[href^="index.php?page=bookmark"]) - - fields: - category: - selector: a[href*="category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details"] - details: - selector: a[href^="index.php?page=torrent-details"] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - poster: - selector: a[href^="index.php?page=torrent-details"] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - - name: replace - args: ["torrentimg/nocover.jpg", ""] - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd/MM/yyyy" - size: - selector: td:nth-child(11) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - grabs: - selector: td:nth-child(10) - downloadvolumefactor: - case: - img[src$="freeleech.gif"]: 0 - img[src$="gold.gif"]: 0 - img[src$="silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="2x.gif"]: 2 - img[src$="3x.gif"]: 3 - img[src$="4x.gif"]: 4 - img[src$="5x.gif"]: 5 - img[src$="6x.gif"]: 6 - img[src$="7x.gif"]: 7 - img[src$="8x.gif"]: 8 - img[src$="9x.gif"]: 9 - img[src$="10x.gif"]: 10 - "*": 1 - minimumratio: - text: 0.3 -# xbtit dt fm v7.1 customized diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hddolby.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hddolby.yml deleted file mode 100644 index 37ba7f73..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hddolby.yml +++ /dev/null @@ -1,183 +0,0 @@ ---- -id: hddolby -name: HD Dolby -description: "HD Dolby is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.hddolby.com/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies电影"} - - {id: 402, cat: TV, desc: "TV Series电视剧"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations动漫"} - - {id: 403, cat: TV, desc: "TV Shows综艺"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - {id: 407, cat: TV/Sport, desc: "Sports体育"} - - {id: 408, cat: Audio, desc: "HQ Audio音乐"} - - {id: 410, cat: PC/Games, desc: "Games游戏"} - - {id: 411, cat: Other, desc: "Study学习"} - - {id: 409, cat: Other, desc: "Others其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. (Division Commander) Extreme User and above will be retained forever
  2. (Brigadier Commander) Veteran Users and above will not be deleted after their account has been parked (in the Control Panel)
  3. Users who have parked their accounts will be disabled if they do not log in for 180 consecutive days
  4. Users with a non-parked account will have their account disabled if they do not log in for 90 consecutive days
  5. Newly registered users who do not have traffic (i.e., upload and download data is 0) and do not log in for 5 consecutive days will be disabld.
" - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0=incldead, 1=active, 2=dead - incldead: 0 - # show promotions: 0=all, 1=normal, 2=free, 3=2x, 4=2xFree, 5=50%, 6=2x50%, 7=30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0=title, 1=descr, 3=uploader - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0=AND, 1=OR, 2=Exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - tmdbid: - selector: a[href*="themoviedb.org/movie/"], a[href*="themoviedb.org/tv/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.5 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span.tags - filters: - - name: re_replace - args: ["(?i)\\s*(剩余时间:|剩餘時間:|will end in)\\s*", ""] -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdfans.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdfans.yml deleted file mode 100644 index 94ea3768..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdfans.yml +++ /dev/null @@ -1,213 +0,0 @@ ---- -id: hdfans -name: HDFans -description: "HDFans is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdfans.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 403, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 406, cat: Audio, desc: "Music/音乐"} - - {id: 416, cat: TV, desc: "TV Shows/综艺"} - - {id: 417, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 407, cat: Audio/Video, desc: "Music Videos/音乐视频"} - - {id: 408, cat: Audio/Video, desc: "Concert/演唱会"} - - {id: 404, cat: Other, desc: "Education/教育"} - - {id: 405, cat: Audio/Audiobook, desc: "Audio Books/有声读物"} - - {id: 409, cat: Movies/Other, desc: "Drama/戏剧"} - - {id: 418, cat: TV/Sport, desc: "Sports/体育"} - - {id: 419, cat: PC/0day, desc: "Software/软件"} - - {id: 421, cat: PC/Games, desc: "Games/游戏"} - - {id: 423, cat: Books/EBook, desc: "E-Books/电子书"} - - {id: 410, cat: Other, desc: "Others/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the HDFans Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Extreme User and above will be retained forever
  2. Veteran User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 180 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 90 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 15 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 1 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.14 2024-09-24 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdforever.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdforever.yml deleted file mode 100644 index 81dcaf8b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdforever.yml +++ /dev/null @@ -1,290 +0,0 @@ ---- -id: hdforever -name: HD-Forever -description: "HD-Forever (HD-F) is a FRENCH Private Torrent Tracker for HD MOVIES" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://hdf.world/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Film"} - - {id: 2, cat: Movies, desc: "Film d'animation"} - - {id: 3, cat: Movies, desc: "Spectacle"} - - {id: 4, cat: Movies, desc: "Concert"} - - {id: 5, cat: TV, desc: "Série"} - - {id: 6, cat: TV/Anime, desc: "Série d'animation"} - - {id: 7, cat: Movies, desc: "Documentaire"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid] - movie-search: [q, imdbid, tmdbid] - allowtvsearchimdb: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: usetoken - type: checkbox - label: Always try to use the FreeLeech Token - default: false - - name: info_token - type: info - label: About the Freeleech token - default: If you do not have any Freeleech tokens then do not tick this box.
Attempting a token download when you have no tokens will generate an error. - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_radarr - type: info - label: About Radarr - default: The HD-F web site cannot find movies if you use the release year in a title search. When you define your Radarr Indexer remember to tick the Remove year from search string checkbox. - - name: info_release_name - type: info - label: About release names - default: For more stable release names, enable Afficher les noms de fichiers in your HD-F profile settings. - -login: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: form#loginform > span.warning - test: - path: torrents.php - selector: a[href^="logout.php?auth="] - -search: - # https://hdf.world/torrents.php?searchstr=Un+Singe+en+hiver+1962&freetorrent=1&order_by=time&order_way=desc&action=advanced&searchsubmit=1 - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchstr: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - searchsubmit: 1 - # 1 freeleech, 2 neutral, 3 both, 0 normal - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - tmdbid: "{{ .Query.TMDBID }}" - imdbid: "{{ .Query.IMDBID }}" - - rows: - selector: table#torrent_table > tbody > tr.torrent:has(a[href^="torrents.php?action=download&"]) - - fields: - category: - selector: td.cats_col - case: - div.cats_film: 1 - div[class*="cats_filmd"]: 2 - div.cats_spectacle: 3 - div.cats_concert: 4 - div.cats_série: 5 - div[class*="cats_séried"]: 6 - div.cats_documentaire: 7 - details: - selector: a[href^="torrents.php?id="] - attribute: href - download_regular: - selector: a[href^="torrents.php?action=download&"] - attribute: href - download_usetoken: - selector: a[href^="torrents.php?action=download&"] - attribute: href - filters: - - name: append - args: "&usetoken=1" - download: - text: "{{ if .Config.usetoken }}{{ .Result.download_usetoken }}{{ else }}{{ .Result.download_regular }}{{ end }}" - _release_group: - selector: span.team_name - optional: true - filters: - - name: trim - poster: - selector: div.group_image img - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - tmdbid: - selector: a[href*="themoviedb.org/movie/"], a[href*="themoviedb.org/tv/"] - attribute: href - date: - selector: span.time - filters: - - name: replace - args: ["Il y a ", ""] - - name: replace - args: ["heures", "hours"] - - name: replace - args: ["heure", "hour"] - - name: replace - args: ["jours", "days"] - - name: replace - args: ["jour", "day"] - - name: replace - args: ["semaines", "weeks"] - - name: replace - args: ["semaine", "week"] - - name: replace - args: ["mois", "months"] - - name: replace - args: ["ans", "years"] - - name: replace - args: ["an", "year"] - - name: append - args: " ago" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - "div.torrent_info .tl_free": 0 - "div.torrent_info .tl_notice[title*=\"Freeleech\"]": 0 # Global freeleech enabled - "div.torrent_info .tl_notice[title=\"-75%\"]": 0.75 - "div.torrent_info .tl_notice[title=\"-50%\"]": 0.50 - "div.torrent_info .tl_notice[title=\"-25%\"]": 0.25 # for this site this is the count ratio, which is 25% of the total torrent size. (I.E. it's 75% free) - "*": 1 - uploadvolumefactor: - text: 1 - title_torrent_filename: - selector: div.torrent_filename_row - optional: true - filters: - - name: trim - title_parsed: - selector: div.group_info - remove: span.team_name, span:has(a[href^="torrents.php?action=download"]), div.tags, div.vote_controls, div.stats-content, .add_bookmark, img, .tl_free, .tl_notice, .torrent_filename_row - filters: - - name: replace - args: ["\n", " "] - - name: re_replace - args: ["\\[(?:Concert|Film|Film d'animation|Spectacle|Série|Série d'animation|Documentaire)\\]", ""] - - name: replace - args: ["Blu-ray Original", "Complete.BluRay"] - - name: replace - args: ["Blu-ray Remux", "Remux"] - - name: replace - args: ["Blu-ray Rip", "BluRay.Rip"] - - name: replace - args: ["mHD", "mHD.BluRay.Rip"] - - name: re_replace - args: ["(?:\\]|\\/\\s)DC", ".Directors.Cut"] - - name: re_replace - args: ["(?:\\]|\\/\\s)VL", ".Extended"] - - name: re_replace - args: ["(?:\\]|\\/\\s)RM", ".Remastered"] - - name: re_replace - args: ["(?:\\]|\\/\\s)UC", ".Uncut"] - - name: re_replace - args: ["(?:\\]|\\/\\s)UR", ".Unrated"] - - name: re_replace - args: ["(?:\\]|\\/\\s)ES", ".Special.Edition"] - - name: re_replace - args: ["(?:\\]|\\/\\s)Crit", ".Criterion"] - - name: re_replace - args: ["(?:\\/ )?(?:Remplaçable|Complété|Bonus|Note Modo|[2-9]+ème partie|\\b(?:Cust(?:_sub)?|WAC|BFI|MUET)\\b)(?: \\/)?", ""] - - name: trim - - name: re_replace - args: ["[\\[\\]\\/\\s?.,:'!]+", "."] - - name: trim - args: "." - - name: re_replace - args: ["(?i)\\.VFF\\.VFQ\\.(?:MULTI|StFR(?:\\.MULTI)?|VO(?:\\.StFR)?)", ".MULTI.VF2"] - - name: re_replace - args: ["(?i)\\.VFF\\.(?:MULTI|StFR(?:\\.MULTI)?|VO(?:\\.StFR)?)", ".MULTI.VFF"] - - name: re_replace - args: ["(?i)\\.VFQ\\.(?:MULTI|StFR(?:\\.MULTI)?|VO(?:\\.StFR)?)", ".MULTI.VFQ"] - - name: re_replace - args: ["(?i)\\.VOQ\\.(?:MULTI|StFR(?:\\.MULTI)?|VO(?:\\.StFR)?)", ".MULTI.VOQ"] - - name: re_replace - args: ["(?i)\\.VFI\\.(?:MULTI|StFR(?:\\.MULTI)?|VO(?:\\.StFR)?)", ".MULTI.VFI"] - - name: re_replace - args: ["(?i)\\.VOF\\.(?:MULTI|StFR(?:\\.MULTI)?|VO(?:\\.StFR)?)", ".MULTI.VOF"] - - name: re_replace - args: ["(?i)\\.(?:VO\\.)?StFR", ".VOSTFR"] - - name: append - args: "{{ if .Result._release_group }}-{{ .Result._release_group }}{{ else }}{{ end }}" - title_phase1: - text: "{{ if .Result.title_torrent_filename }}{{ .Result.title_torrent_filename }}{{ else }}{{ .Result.title_parsed }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR|VF2|VFF|VFQ|VOQ|VFI|VOF)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - description: - selector: div.group_info - filters: - - name: replace - args: ["\n", " "] - - name: re_replace - args: ["\\s+", " "] -# global MR is 0.6 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.6 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdgalaktik.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdgalaktik.yml deleted file mode 100644 index 0f41913c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdgalaktik.yml +++ /dev/null @@ -1,216 +0,0 @@ ---- -id: hdgalaktik -name: HDGalaKtik -description: "HDGalaKtik is a RUSSIAN Semi-Private tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://www.trackerpmr.com/ - - https://freetmd.com/ - - https://kinoradiomagia.tv/ -legacylinks: - - https://hdgalaktik.com/ - -caps: - categorymappings: - - {id: 13, cat: Movies, desc: "Фильмы"} - - {id: 11, cat: TV, desc: "Сериалы"} - - {id: 6, cat: TV, desc: "Мультфильмы"} - - {id: 10, cat: Audio, desc: "Музыка"} - - {id: 26, cat: PC, desc: "Программы"} - - {id: 5, cat: Console, desc: "Игры"} - - {id: 25, cat: Other, desc: "Картинки"} - - {id: 12, cat: TV/Anime, desc: "Аниме"} - - {id: 16, cat: Books, desc: "Книги"} - - {id: 18, cat: Audio/Video, desc: "Клипы / Ролики"} - - {id: 22, cat: TV, desc: "ТВ / Передачи"} - - {id: 27, cat: PC/Mobile-Other, desc: "Игры - Мобила / КПК"} - - {id: 1, cat: PC/ISO, desc: "Образы"} - - {id: 4, cat: Other, desc: "Другое"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 1: title - 5: size - 7: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using the cookie method because the site expects a single session only, so using browser and Jackett can cause a lockout - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href="mybonus.php"] - -search: - # https://www.trackerpmr.com/browse.php?cat[]=0&shw_incl_cats=0&incldead=0&search=the+fix&tag=#results - paths: - - path: browse.php - inputs: - # 0 default, 1 active, 2 dead, 3 gold, 4 sticky, lots of others - incldead: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - name: re_replace # replace special characters with "%" (wildcard) - args: ["[^a-zA-Z0-9]+", "%"] - - rows: - selector: div.tr-torrent-card-compact:not(:has(div.tr-sticky-highlight)) - dateheaders: - selector: a[href*="date="] - attribute: href - filters: - - name: querystring - args: date - - name: dateparse - args: "yyyy-MM-dd" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a.tr-title-link-compact - attribute: title - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(?i)\\bFiles-х\\b", "Files-x"] - - name: re_replace - args: ["(?i)\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a.tr-title-link-compact - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - optional: true # for users that do not have DL access - poster: - selector: img.tr-main-poster-compact - attribute: src - size: - selector: div.tr-stat-block-compact:nth-child(3) - seeders: - selector: div.tr-stat-block-compact:nth-child(4) - leechers: - selector: div.tr-stat-block-compact:nth-child(5) - grabs: - selector: div.tr-meta-info-compact span:has(i.fa-download) - downloadvolumefactor: - case: - span.tr-badge-free: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - description: - selector: a.tr-tag-item-compact - attribute: title -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdhome.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdhome.yml deleted file mode 100644 index 2b8655d4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdhome.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -id: hdhome -name: HDHome -description: "HDHome is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdhome.org/ -legacylinks: - - http://hdhome.org/ - -caps: - categorymappings: - - {id: 506, cat: Movies/BluRay, desc: "Movies 8K UHD BD"} - - {id: 499, cat: Movies/BluRay, desc: "Movies UHD Blu-ray"} - - {id: 518, cat: Movies/UHD, desc: "Movies UHD REMUX"} - - {id: 450, cat: Movies/BluRay, desc: "Movies Bluray"} - - {id: 415, cat: Movies/HD, desc: "Movies REMUX"} - - {id: 505, cat: Movies/UHD, desc: "Movies 8K/4320p"} - - {id: 416, cat: Movies/UHD, desc: "Movies 2160p"} - - {id: 414, cat: Movies/HD, desc: "Movies 1080p"} - - {id: 413, cat: Movies/HD, desc: "Movies 720p"} - - {id: 411, cat: Movies/SD, desc: "Movies SD"} - - {id: 412, cat: Movies/SD, desc: "Movies IPad"} - - {id: 523, cat: TV/UHD, desc: "TVSeries 8KUHD"} - - {id: 502, cat: TV/UHD, desc: "TVSeries 4K Bluray"} - - {id: 451, cat: TV/Documentary, desc: "Doc Bluray"} - - {id: 421, cat: TV/Documentary, desc: "Doc REMUX"} - - {id: 526, cat: TV/UHD, desc: "TVSeries 4320p"} - - {id: 431, cat: TV/HD, desc: "TVShow 2160p"} - - {id: 433, cat: TV/SD, desc: "TVSeries IPad"} - - {id: 434, cat: TV/HD, desc: "TVSeries 720p"} - - {id: 435, cat: TV/HD, desc: "TVSeries 1080i"} - - {id: 436, cat: TV/HD, desc: "TVSeries 1080p"} - - {id: 437, cat: TV/HD, desc: "TVSeries REMUX"} - - {id: 453, cat: TV/HD, desc: "TVSereis Bluray"} - - {id: 438, cat: TV/UHD, desc: "TVSeries 2160p"} - - {id: 439, cat: Audio/Other, desc: "Musics APE"} - - {id: 432, cat: TV/SD, desc: "TVSeries SD"} - - {id: 440, cat: Audio/Lossless, desc: "Musics FLAC"} - - {id: 441, cat: Audio/Video, desc: "Musics MV"} - - {id: 503, cat: Audio/Video, desc: "Musics Bluray"} - - {id: 442, cat: TV/Sport, desc: "Sports 720p"} - - {id: 510, cat: TV/Anime, desc: "Anime 8K UHD BD"} - - {id: 443, cat: TV/Sport, desc: "Sports 1080i"} - - {id: 444, cat: TV/Anime, desc: "Anime SD"} - - {id: 445, cat: TV/Anime, desc: "Anime IPad"} - - {id: 446, cat: TV/Anime, desc: "Anime 720p"} - - {id: 447, cat: TV/Anime, desc: "Anime 1080p"} - - {id: 448, cat: TV/Anime, desc: "Anime REMUX"} - - {id: 454, cat: TV/Anime, desc: "Anime Bluray"} - - {id: 531, cat: TV/Anime, desc: "Anime UHD REMUX"} - - {id: 409, cat: Other, desc: "Misc"} - - {id: 449, cat: TV/Anime, desc: "Anime 2160p"} - - {id: 509, cat: TV/Anime, desc: "Anime 8K/4320p"} - - {id: 501, cat: TV/Anime, desc: "Anime UHD Blu-ray"} - - {id: 504, cat: TV/Sport, desc: "Sports 2160p"} - - {id: 511, cat: TV/Sport, desc: "Sport 8K/4320p"} - - {id: 508, cat: TV/Documentary, desc: "Doc 8K UHD BD"} - - {id: 529, cat: TV/Documentary, desc: "Doc 8K UHD BD REMUX"} - - {id: 500, cat: TV/Documentary, desc: "Doc UHD Blu-ray"} - - {id: 507, cat: TV/Documentary, desc: "Doc 8K/4320p"} - - {id: 422, cat: TV/Documentary, desc: "Doc 2160p"} - - {id: 420, cat: TV/Documentary, desc: "Doc 1080p"} - - {id: 419, cat: TV/Documentary, desc: "Doc 720p"} - - {id: 417, cat: TV/Documentary, desc: "Doc SD"} - - {id: 418, cat: TV/Documentary, desc: "Doc IPad"} - - {id: 424, cat: TV/HD, desc: "TVMusic 1080i"} - - {id: 423, cat: TV/HD, desc: "TVMusic 720p"} - - {id: 452, cat: TV/HD, desc: "TVShows Bluray"} - - {id: 430, cat: TV/HD, desc: "TVShow REMUX"} - - {id: 429, cat: TV/HD, desc: "TVShow 1080p"} - - {id: 428, cat: TV/HD, desc: "TVShow 1080i"} - - {id: 427, cat: TV/HD, desc: "TVShow 720p"} - - {id: 425, cat: TV/SD, desc: "TVShow SD"} - - {id: 426, cat: TV/SD, desc: "TVShow IPad"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method due to #6460 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: table.torrentname td a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[title][href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v2.0 2014-11-24 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdkylin.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdkylin.yml deleted file mode 100644 index 90601fee..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdkylin.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: hdkylin -name: HDKylin (麒麟) -description: "HDKylin (麒麟) is a CHINESE Private tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://na.hdkylin.com/ - - https://www.hdkyl.in/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 404, cat: Other, desc: "Record Education/纪录教育"} - - {id: 408, cat: Audio, desc: "HQ Audio/音乐"} - - {id: 421, cat: TV/Other, desc: "Playlet/短剧"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 419, cat: Other, desc: "Study/学习"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育运动"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐视频"} - - {id: 411, cat: PC, desc: "software/软件"} - - {id: 412, cat: Console, desc: "Game/游戏"} - - {id: 413, cat: Books, desc: "Ebook/电子书"} - - {id: 409, cat: Other, desc: "Misc/其他"} - - {id: 420, cat: TV, desc: "TV Shows/综艺"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Retention rules:
  1. [Veteran User] and above will be retained forever.
  2. [Elite User] and above will not be deleted after parking their accounts (in the Control Panel).
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days.
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days.
  5. Users with no traffic (i.e. upload/download data are both 0) will have their accounts deleted if they do not log in for 40 consecutive days.
" - -login: - # using cookie method because the site uses Safeline WAF protection - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.12 2024-07-13 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdolimpo-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdolimpo-api.yml deleted file mode 100644 index ff3efdfe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdolimpo-api.yml +++ /dev/null @@ -1,211 +0,0 @@ ---- -id: hdolimpo-api -name: HD-Olimpo (API) -description: "HD-Olimpo is a SPANISH Private Torrent Tracker for HD MOVIES / TV" -language: es-ES -type: private -encoding: UTF-8 -links: - - https://hd-olimpo.club/ -legacylinks: - - https://hdolimpo.co/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Películas"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 3, cat: Audio, desc: "Música"} - - {id: 4, cat: TV/Documentary, desc: "Documentales"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your account My configuration / Mi configuración => Secutiy / Seguridad page and clicking on the API Token tab." - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - rows: - selector: data - attribute: attributes - - fields: - categorydesc: - selector: category - title_phase1: - selector: name - filters: - - name: re_replace - args: ["(?i)\\b(full uhd|(uhd )?full\\s?(blu-?ray|uhd))\\b", "BRDISK"] - - name: re_replace - args: ["\\bE-AC-3\\b", "EAC3"] - - name: re_replace - args: ["(?i)\\b(es-cat?-en|es-en-cat?|en-cat?-es|en-es-cat?|cat?-es-en|cat?-en-es)\\b", "MULTi SPANiSH CATALAN ENGLiSH"] - - name: re_replace - args: ["(?i)\\b(es-cat?-(ja|ja?p)|es-(ja|ja?p)-cat?|cat?-es-(ja|ja?p)|cat?-(ja|ja?p)-es|(ja|ja?p)-es-cat?|(ja|ja?p)-cat?-es)\\b", "MULTi SPANiSH CATALAN JAPANES"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(?i)\\b(es-en|en-es)\\b", "MULTi SPANiSH ENGLiSH"] - - name: re_replace - args: ["(?i)\\b(es-cat?|cat?-es)\\b", "MULTi SPANiSH CATALAN"] - - name: re_replace - args: ["(?i)\\b(es-(ja|ja?p)|(ja|ja?p)-es)\\b", "MULTi SPANiSH JAPANES"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(?i)\\b(es-fr[ae]?n?|fr[ae]?n?-es)\\b", "MULTi SPANiSH FRENCHx"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(?i)\\b(es-ita?|ita?-es)\\b", "MULTi SPANiSH iTALiAN"] - - name: re_replace - args: ["(?i)\\b(es-rus?|rus?-es)\\b", "MULTi SPANiSH RUSSiAN"] - - name: re_replace - args: ["(?i)\\b(es-(ger?|al(e|em)?)|(ger?|al(e|em)?)-es)\\b", "MULTi SPANiSH GERMANx"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(?i)\\btriaudio\\b", "MULTi SPANiSH"] - - name: re_replace - args: ["(?i)\\b(espa[ñn]ol|castellano|esp)\\b", "SPANiSH"] - - name: re_replace - args: ["(?i)\\b(ingl[ée]s|[ei]ng)\\b", "ENGLiSH"] - - name: re_replace - args: ["(?i)\\bcat\\b", "CATALAN"] - - name: re_replace - args: ["(?i)\\bfr[ae]?n?\\b", "FRENCHx"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(?i)\\b(jap|jp)\\b", "JAPANES"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(?i)\\bita\\b", "iTALiAN"] - - name: re_replace - args: ["(?i)\\brus?\\b", "RUSSiAN"] - - name: re_replace - args: ["(?i)\\b(ger?|alem)\\b", "GERMANx"] # misspelled to accomodate negative lookback, corrected later - - name: re_replace - args: ["(\\s|\\.)+", "$1"] - # add MULTi SPANiSH if not preceded by another language or followed by SPANiSH - - name: re_replace - args: ["(?HD-Only account Access Settings tab and copy the API Key." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: add_hybrid_features_to_filename - type: checkbox - label: Include DV/HDR10 in filename when a release has multiple HDR formats. - default: false - - name: add_version_francophone_to_title - type: checkbox - label: Append Version Francophone flags to titles if available (VFF, VFQ, VFI, VF) - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: ajax.php - method: get - inputs: - action: browse - apikey: "{{ .Config.apikey }}" - -search: - # https://hd-only.org/ajax.php?filelist=blood+2009&action=browse&group_results=0&order_by=time&order_way=desc&freetorrent=1 - paths: - - path: ajax.php - response: - type: json - - inputs: - # does not support categories, uses releasetype for single filter, so we cannot do multi releasetype filtering either. - # $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchstr: "{{ .Keywords }}" - tmdb: "{{ .Query.TMDBID }}" - taglist: "{{ .Query.Genre }}" - action: browse - group_results: 0 - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - # 1 freeleech, 0 normal - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - # site does not support imdbid search or display imdb links in results - apikey: "{{ .Config.apikey }}" - - keywordsfilters: - # strip season - - name: re_replace - args: ["(?i)\\bS\\d{1,3}\\b", ""] - - name: trim - - rows: - selector: response.results - - fields: - _group_id: - selector: group.groupId - _torrent_id: - selector: torrentId - category: - selector: group.categoryId - categorydesc|append: - selector: group.releaseType - optional: true - details: - text: "torrents.php?id={{ .Result._group_id }}&torrentid={{ .Result._torrent_id }}" - download: - text: "ajax.php?action=download&id={{ .Result._torrent_id }}&apikey={{ .Config.apikey }}" - genre: - selector: group.tags - filters: - - name: re_replace - args: ["(?i)(science.fiction)", "science_fiction"] - poster: - selector: group.cover - optional: true - tmdbid: - selector: group.tmdb - files: - selector: fileCount - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: snatches - size: - selector: size - date: - selector: time - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - year: - selector: group.groupYear - optional: true - _season: - selector: season - optional: true - filters: - - name: re_replace - args: ["^0$", ""] - - name: re_replace - args: ["^([1-9])$", "S0$1"] - - name: re_replace - args: ["^(\\d{2,})$", "S$1"] - _episode: - selector: episode - optional: true - filters: - - name: re_replace - args: ["^0$", ""] - - name: re_replace - args: ["^([1-9])$", "E0$1"] - - name: re_replace - args: ["^(\\d{2,})$", "E$1"] - _episode_info: - text: "{{ .Result._season }}{{ .Result._episode }}" - _format: - selector: format - filters: - - name: replace - args: ["Blu-Ray Original", "COMPLETE BLURAY"] - - name: re_replace - args: ["Blu-Ray Remux", "Remux"] - - name: re_replace - args: ["Blu-Ray Rip", "BluRay.Rip"] - - name: re_replace - args: ["mHD", "mHD.BluRay.Rip"] - _encoding: - selector: encoding - _release_group: - selector: team - optional: true - _media: - selector: media - optional: true - _vff: - selector: vff - optional: true - _vfq: - selector: vfq - optional: true - _vf2: - selector: vf2 - optional: true - _vfi: - selector: vfi - optional: true - _vf: - selector: vf - optional: true - _vof: - selector: vof - optional: true - _hdr: - selector: hdr - optional: true - _hdr10plus: - selector: hdr10plus - optional: true - _dovi: - selector: dovi - optional: true - title_optional: - selector: group.groupName - filters: - - name: append - args: ".{{ .Result.year }}.{{ .Result._episode_info }}.{{ .Result._format }}.{{ .Result._encoding }}.{{ .Result._media }}{{ if eq .Result._hdr \"True\" }}.HDR{{ else }}{{ end }}.{{ if eq .Result._hdr10plus \"True\" }}.HDR10+{{ else }}{{ end }}.{{ if eq .Result._dovi \"True\" }}.DoVi{{ else }}{{ end }}" - - name: append - args: "{{ if or (eq .Result._vf2 \"True\") (or (eq .Result._vff \"True\") (or (eq .Result._vfq \"True\") (or (eq .Result._vfi \"True\") (eq .Result._vf \"True\")))) }}.MULTI{{ else }}{{ end }}" - - name: re_replace - args: ["(\\.MULTI)\\1", ".MULTI"] - - name: append - args: "{{ if and (.Config.add_version_francophone_to_title) (eq .Result._vf2 \"True\") }}.VF2{{ else }}{{ end }}" - - name: append - args: "{{ if and (.Config.add_version_francophone_to_title) (eq .Result._vff \"True\") }}.VFF{{ else }}{{ end }}" - - name: append - args: "{{ if and (.Config.add_version_francophone_to_title) (eq .Result._vfq \"True\") }}.VFQ{{ else }}{{ end }}" - - name: append - args: "{{ if and (.Config.add_version_francophone_to_title) (eq .Result._vfi \"True\") }}.VFI{{ else }}{{ end }}" - - name: append - args: "{{ if and (.Config.add_version_francophone_to_title) (eq .Result._vf \"True\") }}.VF{{ else }}{{ end }}" - - name: append - args: "{{ if eq .Result._vof \"True\" }}.FRENCH{{ else }}{{ end }}" - - name: re_replace - args: ["[\\s.]+", "."] - - name: re_replace - args: ["\\.*([\\-:,])\\.*", "$1"] - - name: trim - - name: trim - args: . - - name: append - args: "{{ if .Result._release_group }}-{{ .Result._release_group }}{{ else }}{{ end }}" - title_filename: - selector: "fileName" - optional: true - filters: - - name: re_replace - args: ["\\s÷$", ""] - - name: append - args: " {{ if and (.Config.add_hybrid_features_to_filename) (eq .Result._hdr \"True\") }}[HDR]{{ else }}{{ end }}{{ if and (.Config.add_hybrid_features_to_filename) (eq .Result._hdr10plus \"True\") }}[HDR10+]{{ else }}{{ end }}{{ if and (.Config.add_hybrid_features_to_filename) (eq .Result._dovi \"True\") }}[DoVi]{{ else }}{{ end }}" - title_phase1: - text: "{{ if and (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - description: - text: "{{ .Result.title_filename }}" - filters: - - name: re_replace - args: ["\\s÷$", ""] - _free: - selector: isFreeleech - downloadvolumefactor: - text: "{{ if eq .Result._free \"True\" }}0{{ else }}1{{ end }}" - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# JSON Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdroute.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdroute.yml deleted file mode 100644 index 5e816eee..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdroute.yml +++ /dev/null @@ -1,117 +0,0 @@ ---- -id: hdroute -name: HDRoute -description: "HDRoute is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - http://hdroute.org/ # site does not support https: NET::ERR_CERT_REVOKED 21 Aug 2015 - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "电影 (Movies)"} - - {id: 2, cat: TV/Documentary, desc: "演示/纪录片 (Demo/Docu)"} - - {id: 3, cat: TV, desc: "连续剧 (TV)"} - - {id: 4, cat: TV/Anime, desc: "动画片 (Animation)"} - - {id: 5, cat: Audio/Video, desc: "演唱会/MV (Music Video)"} - - {id: 6, cat: TV/Sport, desc: "体育节目 (Sports)"} - - {id: 7, cat: Audio/Other, desc: "电影音轨 (Soundtracks"} - - {id: 8, cat: Audio/Lossless, desc: "无损音乐 (Lossless)"} - - {id: 9, cat: Other, desc: "其他 (Other)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: login.php?action=login - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: :contains("用户不存在") - - selector: :contains("密码错误") - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # http://hdroute.org/browse.php?s=&dp=0&add=0&action=s&c1=1&c3=1&or=1&imdb= - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - s: "{{ if .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # discount 0 all, 1 none, 2 30%, 3 50%, 4 70%, 5 free, 6 tempo free - dp: 0 - # dates 0 all, 1 this week, 2 this month, 3 this season, 4 this year - add: 0 - # order: 1 uptime, 2 imdb, 3 comment, 4 size, 5 complete, 6 upload - or: 1 - action: s - imdb: "{{ if .Query.IMDBID }}{{ .Query.IMDBIDShort }}{{ else }}{{ end }}" - - rows: - selector: div#unsticky-torrent-table dl, div#sticky-torrent-table dl - - fields: - category: - text: 9 - title: - selector: div.torrent_title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php", "download.php"] - size: - selector: div.torrent_size - date: - selector: div.torrent_added - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - imdbid: - selector: div.torrent-imdb - attribute: href - poster: - selector: div.introForPic img - attribute: src - filters: - - name: replace - args: ["./img/theme1/default.jpg", ""] - seeders: - selector: div.torrent-content-right div:nth-child(5) - leechers: - selector: div.torrent-content-right div:nth-child(6) - downloadvolumefactor: - case: - figure.sprite_tempo_free: 0 - figure.sprite_dlp100: 0 - figure.sprite_dlp000: 0 - figure.sprite_dlp070: 0.7 - figure.sprite_dlp050: 0.5 - figure.sprite_dlp030: 0.3 - "*": 1 - uploadvolumefactor: - case: - "*": 1 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdsky.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdsky.yml deleted file mode 100644 index 78deb234..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdsky.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: hdsky -name: HDSky -description: "HDSky is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdsky.me/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 410, cat: Movies, desc: "iPad/iPad影视"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 402, cat: TV, desc: "TV Series/剧集(分集)"} - - {id: 411, cat: TV, desc: "TV Series/剧集(合集)"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 408, cat: Audio, desc: "HQ Audio/无损音乐"} - - {id: 409, cat: Other, desc: "Misc/其他"} - - {id: 412, cat: TV, desc: "TV Series/海外剧集(分集)"} - - {id: 413, cat: TV, desc: "TV Series/海外剧集(合集)"} - - {id: 414, cat: TV, desc: "TV Shows/海外综艺(分集)"} - - {id: 415, cat: TV, desc: "TV Shows/海外综艺(合集)"} - - {id: 416, cat: TV, desc: "Shortplay/短剧"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - # was switched from form method to cookie method on March 2019, reason was not recorded - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: torrents.php - selector: a[href="logout.php"] - -download: - method: post - headers: - Referer: ["{{ .Config.sitelink }}torrents.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid - # 0=incldead, 1=active, 2=dead - incldead: 0 - # show promotions 0=all, 1=normal, 2=free, 3=2x, 4=2xfree, 5=50%, 6=2x50%, 7=30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0=title, 3=uploader, 4=imdb url - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0=AND, 1=OR, 2=exact - search_mode: 0 - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - download: - selector: form[action*="/download.php?id="] - attribute: action - title_onmouseover: - optional: true - selector: a[onmouseover*="details.php?id="] - title_details: - optional: true - selector: a[title][href^="details.php?id="] - attribute: title - title: - # title_onmouseover is more common but sometimes can be replaced by title_details #15849 - text: "{{ if .Result.title_onmouseover }}{{ .Result.title_onmouseover }}{{ else }}{{ .Result.title_details }}{{ end }}" - _id: - text: "{{ .Result.download }}" - filters: - - name: querystring - args: id - details: - text: "details.php?id={{ .Result._id }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 5 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdspace.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdspace.yml deleted file mode 100644 index 924fe8cb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdspace.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -id: hdspace -name: HD-Space -description: "HD-Space (HDS) is a Private Torrent Tracker for HD MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://hd-space.org/ - -caps: - categorymappings: - - {id: 15, cat: Movies/BluRay, desc: "Movie / Blu-ray"} - - {id: 40, cat: Movies/HD, desc: "Movie / Remux"} - - {id: 18, cat: Movies/HD, desc: "Movie / 720p"} - - {id: 19, cat: Movies/HD, desc: "Movie / 1080p"} - - {id: 46, cat: Movies/UHD, desc: "Movie / 2160p"} - - {id: 21, cat: TV/HD, desc: "TV Show / 720p HDTV"} - - {id: 22, cat: TV/HD, desc: "TV Show / 1080p HDTV"} - - {id: 45, cat: TV/UHD, desc: "TV Show / 2160p HDTV"} - - {id: 24, cat: TV/Documentary, desc: "Documentary / 720p"} - - {id: 25, cat: TV/Documentary, desc: "Documentary / 1080p"} - - {id: 47, cat: TV/Documentary, desc: "Documentary / 2160p"} - - {id: 27, cat: TV/Anime, desc: "Animation / 720p"} - - {id: 28, cat: TV/Anime, desc: "Animation / 1080p"} - - {id: 48, cat: TV/Anime, desc: "Animation / 2160p"} - - {id: 30, cat: Audio/Lossless, desc: "Music / HQ Audio"} - - {id: 31, cat: Audio/Video, desc: "Music / Videos"} - - {id: 33, cat: XXX, desc: "XXX / 720p"} - - {id: 34, cat: XXX, desc: "XXX / 1080p"} - - {id: 49, cat: XXX, desc: "XXX / 2160p"} - - {id: 36, cat: Movies/Other, desc: "Trailers"} - - {id: 37, cat: PC, desc: "Software"} - - {id: 38, cat: Other, desc: "Others"} - - {id: 41, cat: Movies/UHD, desc: "Movie / 4K UHD"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - logout: "" - error: - - selector: tr td span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://hd-space.org/index.php?page=torrents&search=&active=0&options=0&category=15;18;19 - - path: index.php - inputs: - page: torrents - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBIDShort }}{{ else }}{{ .Keywords }}{{ end }}" - category: "{{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}" - # 0 default, 1 genre, 2 imdb, 3 uploader - options: "{{ if .Query.IMDBID }}2{{ else }}0{{ end }}" - # 0 all, 1 activeonly, 2 deadonly - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: "table.lista[width=\"100%\"] > tbody > style ~ tr{{ if .Config.freeleech }}:has(img[src=\"gold/gold.png\"]){{ else }}{{ end }}, table.lista[width=\"100%\"] > tbody > style ~ tr{{ if .Config.freeleech }}:has(img[src=\"images/sf.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: td a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - # change broken/missing cat 0 to Others - - name: re_replace - args: ["\\b0\\b", "38"] - title_default: - selector: td a[href^="index.php?page=torrent-details"] - filters: - - name: htmldecode - title: - selector: a[href^="download.php?id="] - attribute: href - filters: - - name: querystring - args: f - - name: htmldecode - - name: htmldecode # double usage is intentional - - name: replace - args: [".torrent", ""] - - name: trim - optional: true - default: "{{ .Result.title_default }}" - details: - selector: td a[href^="index.php?page=torrent-details"] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: td a[href^="index.php?page=torrent-details"] - attribute: onmouseover - filters: - - name: regexp - args: src=\./(.+?)\s - imdbid: - selector: td a[href^="index.php?page=torrent-details"] - attribute: onmouseover - filters: - - name: regexp - args: /(\d{8}).jpg - date_day: - # Today at 09:17:08 - # Yesterday at 17:11:03 - selector: td:nth-child(5):contains("day") - # auto adjusted by site account profile - optional: true - filters: - - name: re_replace - args: ["[ ]at|[//\xa0\\s,]+", " "] - date_year: - # January 30, 2024, 20:23:21 - selector: td:nth-child(5):not(:contains("day")) - # auto adjusted by site account profile - optional: true - filters: - - name: re_replace - args: ["[//\xa0\\s,]+", " "] - - name: dateparse - args: "MMMM dd yyyy HH:mm:ss" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - grabs: - selector: td:nth-child(10) - genre: - selector: td:nth-child(2) - remove: a - description: - text: "{{ .Result.genre }}" - downloadvolumefactor: - case: - img[src="images/sf.png"]: 0 # side freeleech - img[src="gold/gold.png"]: 0 - img[src="gold/silver.png"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# xbtit diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtime.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtime.yml deleted file mode 100644 index 26c6c946..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtime.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -id: hdtime -name: HDtime -description: "HDtime is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdtime.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 424, cat: Movies/BluRay, desc: "Blu-Ray原盘"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 414, cat: PC, desc: "Apps/软件"} - - {id: 407, cat: TV/Sport, desc: "Sports/运体"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 406, cat: Audio/Video, desc: "MusicVideo/音乐MV"} - - {id: 408, cat: Audio, desc: "Music/音乐"} - - {id: 410, cat: PC/Games, desc: "Games/游戏"} - - {id: 411, cat: Books, desc: "Books/文档"} - - {id: 409, cat: Other, desc: "Misc/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users with no traffic (ie, upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because takelogin.php returns 302 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.81 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.15 2024-11-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtorrents.yml deleted file mode 100644 index 940d68de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtorrents.yml +++ /dev/null @@ -1,190 +0,0 @@ ---- -id: hdtorrents -name: HD-Torrents -description: "HD-Torrents (HDT) is a Private Torrent Tracker for HD MOVIES / TV / MUSIC / 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://hdts.ru/ - - https://hd-torrents.org/ - - https://hd-torrents.net/ - - https://hd-torrents.me/ - -caps: - categorymappings: - - {id: 70, cat: Movies/BluRay, desc: "Movie/UHD/Blu-Ray"} - - {id: 1, cat: Movies/BluRay, desc: "Movie/Blu-Ray"} - - {id: 71, cat: Movies/UHD, desc: "Movie/UHD/Remux"} - - {id: 2, cat: Movies/HD, desc: "Movie/Remux"} - - {id: 5, cat: Movies/HD, desc: "Movie/1080p/i"} - - {id: 3, cat: Movies/HD, desc: "Movie/720p"} - - {id: 64, cat: Movies/UHD, desc: "Movie/2160p"} - - {id: 63, cat: Audio, desc: "Movie/Audio Track"} - - {id: 72, cat: TV/UHD, desc: "TV Show/UHD/Blu-ray"} - - {id: 59, cat: TV/HD, desc: "TV Show/Blu-ray"} - - {id: 73, cat: TV/UHD, desc: "TV Show/UHD/Remux"} - - {id: 60, cat: TV/HD, desc: "TV Show/Remux"} - - {id: 30, cat: TV/HD, desc: "TV Show/1080p/i"} - - {id: 38, cat: TV/HD, desc: "TV Show/720p"} - - {id: 65, cat: TV/UHD, desc: "TV Show/2160p"} - - {id: 44, cat: Audio, desc: "Music/Album"} - - {id: 61, cat: Audio/Video, desc: "Music/Blu-Ray"} - - {id: 62, cat: Audio/Video, desc: "Music/Remux"} - - {id: 57, cat: Audio/Video, desc: "Music/1080p/i"} - - {id: 45, cat: Audio/Video, desc: "Music/720p"} - - {id: 66, cat: Audio/Video, desc: "Music/2160p"} - - {id: 58, cat: XXX, desc: "XXX/Blu-ray"} - - {id: 78, cat: XXX, desc: "XXX/Remux"} - - {id: 74, cat: XXX, desc: "XXX/UHD/Blu-ray"} - - {id: 48, cat: XXX, desc: "XXX/1080p/i"} - - {id: 47, cat: XXX, desc: "XXX/720p"} - - {id: 67, cat: XXX, desc: "XXX/2160p"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - size: size - seeds: seeders - filename: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "If you do not log in for 50 days, your account will be disabled for inactivity. If you are VIP you won't be disabled until the VIP period is over." - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: login.php - method: form - form: form - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: div > font[color="#FF0000"] - test: - path: / - selector: a[href^="logout.php?check_hash="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}category[]={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 All, 1 ActiveOnly, 2 DeadOnly, 5 Free, 6 50, 7 25, 8 75 - active: "{{ if .Config.freeleech }}5{{ else }}0{{ end }}" - # 0 title, 3 title and descr, 1 genre, 2 imdb - options: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - keywordsfilters: - # manually url encode parenthesis to prevent "hacking" detection, remove . as not used in titles - - name: re_replace - args: ["\\.", " "] - - name: re_replace - args: ["\\(", "%28"] - - name: re_replace - args: ["\\)", "%29"] - - rows: - selector: "table.mainblockcontenttt > tbody > tr:has(td.mainblockcontent):not(:first-of-type){{ if .Config.freeleech }}:has(img[src=\"images/sign_free.png\"]){{ else }}{{ end }}" - - fields: - _has_freeleech: - case: - ":root table.navus tr td:nth-child(2):contains(\" VIP\")": yes - ":root table.navus tr td:nth-child(2):contains(\" Uploader\")": yes - ":root table.navus tr td:nth-child(2):contains(\" HD Internal\")": yes - ":root table.navus tr td:nth-child(2):contains(\" Moderator\")": yes - ":root table.navus tr td:nth-child(2):contains(\" Administrator\")": yes - ":root table.navus tr td:nth-child(2):contains(\" Owner\")": yes - "*": no - category: - selector: a[href^="torrents.php?category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: td:nth-child(3) span - description: - text: "{{ .Result.genre }}" - date: - # auto adjusted by site account profile - selector: td:nth-child(7) - filters: - - name: dateparse - args: "HH:mm:ss dd/MM/yyyy" - size: - selector: td:nth-child(8) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - grabs: - selector: td:nth-last-child(1) - downloadvolumefactor_freeleech: - case: - img[src$="no_ratio.png"]: 0 - img[src$="free.png"]: 0 - img[src$="50.png"]: 0.5 - img[src$="25.png"]: 0.75 - img[src$="75.png"]: 0.25 - "*": 1 - downloadvolumefactor: - text: "{{ if eq .Result._has_freeleech \"yes\" }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor: - case: - img[src$="no_ratio.png"]: 0 - "*": 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtorrentsit.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtorrentsit.yml deleted file mode 100644 index fee5721b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdtorrentsit.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: hdtorrentsit -name: HDTorrents.it (API) -description: "HDTorrents.it is an ITALIAN Private PAY2DL site for MOVIES / TV" -language: it-IT -type: private -encoding: UTF-8 -links: - - https://hdtorrents.eu/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV Series"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: Console, desc: "Game"} - - {id: 5, cat: PC, desc: "Application"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your HDTorrents.it account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - name: re_replace # S01 to Stagione 1 - args: ["(?i)\\bS0*(\\d+)\\b", "Stagione $1"] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - filters: - - name: re_replace - args: ["\\.", " "] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# as a pay site there is no MR or MST -# json UNIT3D 9.0.9 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdturk.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdturk.yml deleted file mode 100644 index 3db91cd8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdturk.yml +++ /dev/null @@ -1,189 +0,0 @@ ---- -id: hdturk -name: HDTurk -description: "HDTurk is a TURKISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: tr-TR -type: private -encoding: UTF-8 -links: - - https://hdturk.club/ -legacylinks: - - http://hdturk.de/ - - https://hdturk.de/ - -caps: - categorymappings: - - {id: 37, cat: Other, desc: "Diğer"} - - {id: 210, cat: Books/EBook, desc: "E-Kitap"} - - {id: 208, cat: Other, desc: "Eğitim"} - - {id: 101, cat: Other, desc: "Resim"} - - {id: 15, cat: TV, desc: "TV Dizi/Show/Spor"} - - {id: 18, cat: TV, desc: "TV Dublaj"} - - {id: 81, cat: TV, desc: "TV Show"} - - {id: 207, cat: TV/Sport, desc: "TV Spor"} - - {id: 17, cat: TV, desc: "TV Yabancı"} - - {id: 16, cat: TV, desc: "TV Yerli"} - - {id: 19, cat: TV/HD, desc: "TV Yerli HD"} - - {id: 170, cat: Movies/HD, desc: "Movies HD"} - - {id: 175, cat: Movies/HD, desc: "Movies 1080p Remux DUAL"} - - {id: 226, cat: Movies/HD, desc: "Movies 1080p Remux TR"} - - {id: 183, cat: Movies/HD, desc: "Movies 1080p Remux Yabancı"} - - {id: 174, cat: Movies/3D, desc: "Movies 3D DUAL"} - - {id: 180, cat: Movies/3D, desc: "Movies 3D TR"} - - {id: 181, cat: Movies/3D, desc: "Movies 3D Yabancı"} - - {id: 217, cat: Movies/UHD, desc: "Movies 4K DUAL"} - - {id: 224, cat: Movies/UHD, desc: "Movies 4K Remux DUAL"} - - {id: 225, cat: Movies/UHD, desc: "Movies 4K Remux Yabanci"} - - {id: 218, cat: Movies/UHD, desc: "Movies 4K TR"} - - {id: 216, cat: Movies/UHD, desc: "Movies 4K Yabancı"} - - {id: 205, cat: Movies/DVD, desc: "Movies DVD DUAL"} - - {id: 206, cat: Movies/DVD, desc: "Movies DVD TR/Yerli"} - - {id: 219, cat: Movies/DVD, desc: "Movies DVD Yabancı"} - - {id: 173, cat: Movies/BluRay, desc: "Movies Full BD"} - - {id: 171, cat: Movies/HD, desc: "Movies x264 DUAL"} - - {id: 176, cat: Movies/HD, desc: "Movies x264 TR"} - - {id: 177, cat: Movies/HD, desc: "Movies x264 Yabancı"} - - {id: 172, cat: Movies/HD, desc: "Movies x265 DUAL"} - - {id: 178, cat: Movies/HD, desc: "Movies x265 TR"} - - {id: 179, cat: Movies/HD, desc: "Movies x265 Yabancı"} - - {id: 198, cat: Movies/HD, desc: "Movies XviD DUAL"} - - {id: 200, cat: Movies/HD, desc: "Movies XviD TR"} - - {id: 202, cat: Movies/HD, desc: "Movies XviD Yabancı"} - - {id: 33, cat: Audio, desc: "MÜZİK"} - - {id: 34, cat: Audio, desc: "MÜZİK Albüm"} - - {id: 35, cat: Audio, desc: "MÜZİK Diskografi"} - - {id: 98, cat: Audio, desc: "MÜZİK Single"} - - {id: 36, cat: Audio/Video, desc: "MÜZİK Video"} - - {id: 56, cat: Console, desc: "OYUN"} - - {id: 59, cat: Console, desc: "Konsol"} - - {id: 58, cat: PC/Mobile-Android, desc: "Mobil/PDA"} - - {id: 57, cat: PC/Games, desc: "PC"} - - {id: 52, cat: PC, desc: "PROGRAM"} - - {id: 55, cat: PC/Mobile-Android, desc: "Android"} - - {id: 223, cat: PC, desc: "Linux"} - - {id: 54, cat: PC/Mac, desc: "macOS"} - - {id: 53, cat: PC/0day, desc: "Windows"} - - {id: 182, cat: Movies/SD, desc: "SD"} - - {id: 191, cat: Movies/SD, desc: "x264 DUAL"} - - {id: 192, cat: Movies/SD, desc: "x264 TR"} - - {id: 193, cat: Movies/SD, desc: "x264 Yabancı"} - - {id: 194, cat: Movies/SD, desc: "x265 DUAL"} - - {id: 195, cat: Movies/SD, desc: "x265 TR"} - - {id: 196, cat: Movies/SD, desc: "x265 Yabancı"} - - {id: 201, cat: Movies/SD, desc: "XviD DUAL"} - - {id: 197, cat: Movies/SD, desc: "XviD TR"} - - {id: 199, cat: Movies/SD, desc: "XviD Yabancı"} - - {id: 221, cat: Other, desc: "VIP"} - - {id: 85, cat: XXX, desc: "XXX"} - - {id: 92, cat: XXX/Other, desc: "XXX E-Magazin"} - - {id: 220, cat: XXX/Other, desc: "XXX Hentai"} - - {id: 93, cat: XXX, desc: "XXX Movie"} - - {id: 95, cat: XXX/Pack, desc: "XXX Packs"} - - {id: 96, cat: XXX/ImageSet, desc: "XXX Pics"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - -login: - # switched from the form method to the cookie method due to #9270 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="/logout.php?logouthash="] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ .DownloadUri.Query.id }}" - selectors: - - selector: a[href*="download.php?id="] - attribute: href - -search: - paths: - - path: browse.php - keywordsfilters: - - name: re_replace # E01 to BL01 - args: ["(?i)\\bE(\\d+)\\b", "E$1"] - inputs: - # does not support multi categories so defaulting to ALL - do: search - keywords: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - category: 0 - # t_name, t_description, t_both, t_uploader, t_genre - search_type: "{{ if .Query.IMDBID }}t_genre{{ else }}t_name{{ end }}" - include_dead_torrents: yes - # does not return imdb link in results - - rows: - selector: "table#sortabletable tbody tr:has(div[id^=\"port-target-\"]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - title: - selector: div[id^="port-target-"] a - filters: - - name: re_replace # BL01 to E01 - args: ["(?i)\\bBL(\\d+)\\b", " E$1 "] - details: - selector: div[id^="port-target-"] a - attribute: href - category: - selector: a[href*="browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - poster: - selector: div[id^="port-content-"] img - attribute: src - date: - selector: td:nth-child(2) > div:nth-last-child(1) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd-MM-yyyy HH:mm" - download: - selector: div[id^="port-target-"] a - attribute: href - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - img[src$="/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3.75days (as seconds = 3.75 x 24 x 60 x 60) - text: 324000 -# TS Special Edition v.7.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdu.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdu.yml deleted file mode 100644 index 18d49a35..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdu.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: hdu -name: HDU -description: "HDU is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.upxin.net/ -legacylinks: - - http://pt.upxin.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动画"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐 MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 408, cat: Audio, desc: "HQ Audio/无损音乐"} - - {id: 411, cat: Other, desc: "Misc/其他"} - - {id: 410, cat: PC/Games, desc: "Games/游戏"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 365 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 120 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 3 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0=incldead, 1=active, 2=dead - incldead: 0 - # show promotions: 0=all, 1=normal, 2=free, 3=2x, 4=2xFree, 5=50%, 6=2x50%, 7=30%, 8 all promotions - spstate: "{{ if .Config.freeleech }}8{{ else }}0{{ end }}" - # 0=title, 1=descr, 3=uploader, 4=imdb URL - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0=AND, 1=OR, 2=Exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdvideo.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdvideo.yml deleted file mode 100644 index b34b1beb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdvideo.yml +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: hdvideo -name: HDVideo -description: "HDVideo is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hdvideo.top/ - -caps: - categorymappings: - - {id: 406, cat: Audio, desc: "HQ Audio/音轨"} - - {id: 408, cat: Audio/Video, desc: "MV/音乐"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 405, cat: TV/Anime, desc: "Anime/动漫"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the HDVideo Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive (i.e. not logged in for a long time) are automatically deleted." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdzero.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdzero.yml deleted file mode 100644 index 159d0f57..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hdzero.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: hdzero-api -name: HDZero (API) -description: "HD Zero is a SPANISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: es-ES -type: private -encoding: UTF-8 -links: - - https://hdzero.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Películas"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 5, cat: TV, desc: "Series Emision"} - - {id: 6, cat: TV/Anime, desc: "Animacion Peliculas"} - - {id: 7, cat: TV/Anime, desc: "Animacion Series"} - - {id: 8, cat: TV/Anime, desc: "Anime Peliculas"} - - {id: 9, cat: TV/Anime, desc: "Anime Series"} - - {id: 10, cat: TV/Documentary, desc: "Documentales"} - - {id: 11, cat: TV/Documentary, desc: "Documentales Series"} - - {id: 12, cat: TV, desc: "Programas TV"} - - {id: 13, cat: TV, desc: "Telenovelas"} - - {id: 3, cat: Other, desc: "Delete"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your HD Zero account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }} Spanish" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 -# json UNIT3D 9.1.5 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hebits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hebits.yml deleted file mode 100644 index d534e536..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hebits.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -id: hebits -name: Hebits -description: "Hebits (HB) is an ISRAELI Private Torrent Tracker for MOVIES / TV / GENERAL." -language: he-IL -type: private -encoding: UTF-8 -# Status: https://status.hebits.net/ -links: - - https://hebits.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "סרטים (Movies)"} - - {id: 2, cat: TV, desc: "סדרות (TV)"} - - {id: 3, cat: TV/Other, desc: "הצגות והופעות (Theater)"} - - {id: 4, cat: PC/0day, desc: "תוכנות (Apps)"} - - {id: 5, cat: PC/Games, desc: "משחקים (Games)"} - - {id: 6, cat: Audio, desc: "מוזיקה (Music)"} - - {id: 7, cat: Books, desc: "ספרים (Books)"} - - {id: 8, cat: Movies/Other, desc: "חבילות סרטים (Movies Packs)"} - - {id: 9, cat: XXX, desc: "פורנו (Porn)"} - - {id: 10, cat: Other, desc: "שונות (Other)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "There is an automatic system for blocking inactive accounts. The system will block any account below Heb Prophet rank without activity for at least 90 days." - - name: info_results - type: info - label: Search results - default: "If you are getting the error Login failed: Selector \"a[href*=\"/logout.php?auth=\"]\" didn't match then first access the site with your browser and check that you are not being forced to change your password because it has expired after 180 days." - -login: - # using cookie method because login page has a custom captcha with multiple possible answers on individual radio buttons not supported via yaml - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href*="/logout.php?auth="] - -search: - paths: - - path: ajax.php - response: - type: json - - inputs: - searchstr: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }} {{ else }}{{ end }}{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - imdbgt: 0 - imdblt: 10 - # 1 freeleech, 2 neutral, 3 50%, 4 2x, 5 3x, 9 75%, 11 only perks, 0 normal - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - action: browse - group_results: 0 - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - - rows: - selector: response.results - attribute: torrents - multiple: true - - fields: - _id: - selector: torrentId - category: - selector: ..categoryID - title_notenglish: - selector: release:not(:contains("a")):not(:contains("e")):not(:contains("i")):not(:contains("o")):not(:contains("u")):not(:contains("y")) - optional: true - filters: - # space out the book language at the end of the title - # everything within the outermost quotes below is right to left - - name: re_replace - args: ["(אנגלית)$", " - אנגלית"] # English - - name: re_replace - args: ["(עברית)$", " - עברית"] # Hebrew - title_english: - selector: release - optional: true - filters: - - name: append - args: "א" # catch English only results - - name: regexp - args: "(.+?[a-zA-Z0-9\\]\\)\\}])[א-ת].*" # remove Hebrew labels from end of title, account for titles ending in letters, numbers, and brackets - title: - text: "{{ if .Result.title_notenglish }}{{ .Result.title_notenglish }}{{ else }}{{ .Result.title_english }}{{ end }}" - details: - text: "/torrents.php?torrentid={{ .Result._id }}" - download: - text: "/torrents.php?action=download&id={{ .Result._id }}" - poster: - selector: ..cover - imdbid: - selector: ..catalogue - files: - selector: fileCount - date: - selector: time - filters: - - name: append - args: " +02:00" # IST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: size - grabs: - selector: snatches - seeders: - selector: seeders - leechers: - selector: leechers - description: - selector: ..tags - _half: - selector: isHalfFreeleech - _free: - selector: isFreeleech - _x2: - selector: isUploadX2 - _x3: - selector: isUploadX3 - _quarter: - selector: isQuarterLeech - _neutral: - selector: isNeutralLeech - _personal: - selector: isPersonalFreeleech - downloadvolumefactor_special: - optional: true - text: "{{ if eq .Result._half \"True\" }}0.5{{ else }}{{ end }}{{ if eq .Result._quarter \"True\" }}0.25{{ else }}{{ end }}{{ if eq .Result._neutral \"True\" }}0{{ else }}{{ end }}{{ if eq .Result._free \"True\" }}0{{ else }}{{ end }}{{ if eq .Result._personal \"True\" }}0{{ else }}{{ end }}" - downloadvolumefactor: - text: "{{ if .Result.downloadvolumefactor_special }}{{ .Result.downloadvolumefactor_special }}{{ else }}1{{ end }}" - uploadvolumefactor_special: - optional: true - text: "{{ if eq .Result._x3 \"True\" }}3{{ else }}{{ end }}{{ if eq .Result._x2 \"True\" }}2{{ else }}{{ end }}{{ if eq .Result._neutral \"True\" }}0{{ else }}{{ end }}" - uploadvolumefactor: - text: "{{ if .Result.uploadvolumefactor_special }}{{ .Result.uploadvolumefactor_special }}{{ else }}1{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# JSON Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellashut-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellashut-api.yml deleted file mode 100644 index 90ede68a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellashut-api.yml +++ /dev/null @@ -1,169 +0,0 @@ ---- -id: hellashut-api -name: HellasHut (API) -description: "HellasHut is a GREEK Private Torrent Tracker for MOVIES / TV / GENERAL" -language: el-GR -type: private -encoding: UTF-8 -testlinktorrent: false -links: - - https://hellashut.net/ -legacylinks: - - https://hellashut.de/ - -caps: - categorymappings: - - {id: 85, cat: TV/Anime, desc: "Anime/Cartoon 4K"} - - {id: 28, cat: TV/Anime, desc: "Anime/Cartoon HD"} - - {id: 47, cat: TV/Anime, desc: "Anime/Cartoon HDTV"} - - {id: 48, cat: TV/Anime, desc: "Anime/Cartoon SD"} - - {id: 50, cat: TV/Anime, desc: "Anime/Cartoon SD Emb"} - - {id: 51, cat: TV/Anime, desc: "Anime/Cartoon TV"} - - {id: 77, cat: PC, desc: "Applications DOS"} - - {id: 20, cat: PC, desc: "Applications Linux"} - - {id: 19, cat: PC/Mac, desc: "Applications MAC"} - - {id: 68, cat: PC, desc: "Applications OS2 WARP"} - - {id: 21, cat: PC, desc: "Applications Other"} - - {id: 18, cat: PC/0day, desc: "Applications Windows"} - - {id: 52, cat: Books, desc: "Books/Magazines All"} - - {id: 86, cat: Audio/Audiobook, desc: "Books/Magazines AudioBooks"} - - {id: 9, cat: TV/Documentary, desc: "Documentaries All"} - - {id: 53, cat: TV/Documentary, desc: "Documentaries Greek"} - - {id: 54, cat: TV/Documentary, desc: "Documentaries HD"} - - {id: 55, cat: Movies/DVD, desc: "DVD-R All"} - - {id: 17, cat: Console/Other, desc: "Games Other"} - - {id: 11, cat: Console/PS4, desc: "Games PSX"} - - {id: 10, cat: PC/Games, desc: "Games Windows"} - - {id: 13, cat: Console/XBox, desc: "Games Xbox"} - - {id: 56, cat: Other, desc: "Miscellaneous All"} - - {id: 57, cat: PC/Mobile-Other, desc: "Mobile All"} - - {id: 1, cat: Movies/3D, desc: "Movies 3D"} - - {id: 2, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 42, cat: Movies/BluRay, desc: "Movies BluRay"} - - {id: 78, cat: Movies/SD, desc: "Movies CAM"} - - {id: 58, cat: Movies/DVD, desc: "Movies DVD"} - - {id: 84, cat: Movies, desc: "Movies Fake HD"} - - {id: 4, cat: Movies/HD, desc: "Movies HD"} - - {id: 69, cat: Movies/HD, desc: "Movies HD Packs"} - - {id: 3, cat: Movies/SD, desc: "Movies SD"} - - {id: 67, cat: Movies/SD, desc: "Movies SD Packs"} - - {id: 71, cat: Audio, desc: "Music CDA"} - - {id: 59, cat: Audio, desc: "Music Discographies"} - - {id: 23, cat: Audio/Lossless, desc: "Music FLAC"} - - {id: 22, cat: Audio/MP3, desc: "Music MP3"} - - {id: 25, cat: Audio/Video, desc: "Music Video"} - - {id: 35, cat: TV/Sport, desc: "Sports HD"} - - {id: 34, cat: TV/Sport, desc: "Sports SD"} - - {id: 80, cat: Books, desc: "Tutorials Books"} - - {id: 79, cat: TV/Other, desc: "Tutorials Video"} - - {id: 5, cat: TV, desc: "TV Greek Movies"} - - {id: 6, cat: TV, desc: "TV Greek Series"} - - {id: 7, cat: TV, desc: "TV Greek Series Packs"} - - {id: 60, cat: TV, desc: "TV Greek Shows"} - - {id: 41, cat: TV/HD, desc: "TV HD"} - - {id: 61, cat: TV, desc: "TV Series"} - - {id: 82, cat: TV/UHD, desc: "TV Series 4K"} - - {id: 83, cat: TV/UHD, desc: "TV Series 4K Packs"} - - {id: 62, cat: TV, desc: "TV Series Emb"} - - {id: 63, cat: TV, desc: "TV Series Emb Packs"} - - {id: 64, cat: TV/HD, desc: "TV Series HD"} - - {id: 65, cat: TV/HD, desc: "TV Series HD Packs"} - - {id: 66, cat: TV, desc: "TV Series Packs"} - - {id: 73, cat: Movies/BluRay, desc: "UNTOUCHED BluRay"} - - {id: 72, cat: Movies/DVD, desc: "UNTOUCHED DVD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: passkey - type: text - label: PassKey - - name: info_key - type: info - label: About your PassKey - default: "Find your PassKey by accessing your HellasHut profile on the My Profile page and scrolling down to the PassKey field." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive for more than 45 days are automatically deleted." - -login: - path: api.php - method: get - inputs: - passkey: "{{ .Config.passkey }}" - error: - - selector: ":root:contains(\"Bad passkey\")" - -search: - paths: - - path: api.php - response: - type: json - - inputs: - # API documentation is at https://hellashut.net/getapi.php - # https://hellashut.net/api.php?passkey=1234567890abcdef&search=&incldead=1&freeleech=0&c[]=85 - passkey: "{{ .Config.passkey }}" - $raw: "{{ range .Categories }}&c[]={{.}}{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 both, 2 dead - incldead: 1 - # 0 all, 1 nofree, 2 free - freeleech: "{{ if .Config.freeleech }}2{{ else }}{{ end }}" - - rows: - selector: torrents - count: - selector: results - - fields: - category: - selector: category - title: - selector: name - details: - selector: details - download: - selector: dl - filters: - - name: append - args: "&passkey={{ .Config.passkey }}" - imdbid: - selector: imdb - files: - selector: numfiles - size: - selector: size - date: - # unix - selector: added - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - downloadvolumefactor: - selector: freeleech - # 0 notfree, 1 free - case: - 0: 1 - 1: 0 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 10 day (as seconds = 10 x 24 x 60 x 60) - text: 864000 -# JSON honAPI v2.2.6 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellashut.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellashut.yml deleted file mode 100644 index 53725e2e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellashut.yml +++ /dev/null @@ -1,215 +0,0 @@ ---- -id: hellashut -name: HellasHut -description: "HellasHut is a GREEK Private Torrent Tracker for MOVIES / TV / GENERAL" -language: el-GR -type: private -encoding: UTF-8 -testlinktorrent: false -links: - - https://hellashut.net/ -legacylinks: - - https://hellashut.de/ - -caps: - categorymappings: - - {id: 85, cat: TV/Anime, desc: "Anime/Cartoon 4K"} - - {id: 28, cat: TV/Anime, desc: "Anime/Cartoon HD"} - - {id: 47, cat: TV/Anime, desc: "Anime/Cartoon HDTV"} - - {id: 48, cat: TV/Anime, desc: "Anime/Cartoon SD"} - - {id: 50, cat: TV/Anime, desc: "Anime/Cartoon SD Emb"} - - {id: 51, cat: TV/Anime, desc: "Anime/Cartoon TV"} - - {id: 77, cat: PC, desc: "Applications DOS"} - - {id: 20, cat: PC, desc: "Applications Linux"} - - {id: 19, cat: PC/Mac, desc: "Applications MAC"} - - {id: 68, cat: PC, desc: "Applications OS2 WARP"} - - {id: 21, cat: PC, desc: "Applications Other"} - - {id: 18, cat: PC/0day, desc: "Applications Windows"} - - {id: 52, cat: Books, desc: "Books/Magazines All"} - - {id: 86, cat: Audio/Audiobook, desc: "Books/Magazines AudioBooks"} - - {id: 9, cat: TV/Documentary, desc: "Documentaries All"} - - {id: 53, cat: TV/Documentary, desc: "Documentaries Greek"} - - {id: 54, cat: TV/Documentary, desc: "Documentaries HD"} - - {id: 55, cat: Movies/DVD, desc: "DVD-R All"} - - {id: 17, cat: Console/Other, desc: "Games Other"} - - {id: 11, cat: Console/PS4, desc: "Games PSX"} - - {id: 10, cat: PC/Games, desc: "Games Windows"} - - {id: 13, cat: Console/XBox, desc: "Games Xbox"} - - {id: 56, cat: Other, desc: "Miscellaneous All"} - - {id: 57, cat: PC/Mobile-Other, desc: "Mobile All"} - - {id: 1, cat: Movies/3D, desc: "Movies 3D"} - - {id: 2, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 42, cat: Movies/BluRay, desc: "Movies BluRay"} - - {id: 78, cat: Movies/SD, desc: "Movies CAM"} - - {id: 58, cat: Movies/DVD, desc: "Movies DVD"} - - {id: 84, cat: Movies, desc: "Movies Fake HD"} - - {id: 4, cat: Movies/HD, desc: "Movies HD"} - - {id: 69, cat: Movies/HD, desc: "Movies HD Packs"} - - {id: 3, cat: Movies/SD, desc: "Movies SD"} - - {id: 67, cat: Movies/SD, desc: "Movies SD Packs"} - - {id: 71, cat: Audio, desc: "Music CDA"} - - {id: 59, cat: Audio, desc: "Music Discographies"} - - {id: 23, cat: Audio/Lossless, desc: "Music FLAC"} - - {id: 22, cat: Audio/MP3, desc: "Music MP3"} - - {id: 25, cat: Audio/Video, desc: "Music Video"} - - {id: 35, cat: TV/Sport, desc: "Sports HD"} - - {id: 34, cat: TV/Sport, desc: "Sports SD"} - - {id: 80, cat: Books, desc: "Tutorials Books"} - - {id: 79, cat: TV/Other, desc: "Tutorials Video"} - - {id: 5, cat: TV, desc: "TV Greek Movies"} - - {id: 6, cat: TV, desc: "TV Greek Series"} - - {id: 7, cat: TV, desc: "TV Greek Series Packs"} - - {id: 60, cat: TV, desc: "TV Greek Shows"} - - {id: 41, cat: TV/HD, desc: "TV HD"} - - {id: 61, cat: TV, desc: "TV Series"} - - {id: 82, cat: TV/UHD, desc: "TV Series 4K"} - - {id: 83, cat: TV/UHD, desc: "TV Series 4K Packs"} - - {id: 62, cat: TV, desc: "TV Series Emb"} - - {id: 63, cat: TV, desc: "TV Series Emb Packs"} - - {id: 64, cat: TV/HD, desc: "TV Series HD"} - - {id: 65, cat: TV/HD, desc: "TV Series HD Packs"} - - {id: 66, cat: TV, desc: "TV Series Packs"} - - {id: 73, cat: Movies/BluRay, desc: "UNTOUCHED BluRay"} - - {id: 72, cat: Movies/DVD, desc: "UNTOUCHED DVD"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive for more than 45 days are automatically deleted." - -login: - path: login.php - method: form - form: form[action="login.php"] - captcha: - type: image - selector: img[src="captcha.php?ext=.gif"] - input: input[type="text"][size="6"] - inputs: - input[name="username"]: "{{ .Config.username }}" - input[name="password"]: "{{ .Config.password }}" - selectors: true - error: - - selector: td.embedded:contains("Access Denied") - test: - path: / - selector: a[href="account-logout.php"] - -download: - selectors: - - selector: a[href^="download.php?id="], a[href^="magnet:?xt="] - attribute: href - -search: - paths: - # https://hellashut.net/browse.php?search=&incldead=1&freeleech=0&lang=0&blah=0&team=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 notfree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 all, 1 English, 2 French, 3 German, 4 Italian, 5 Japanese, 6 Spanish, 7 Russian, 8 Greek - lang: 0 - # 0 name, 1 descr, 2 uploader - blah: "{{ if .Query.IMDBID }}1{{ else }}0{{ end }}" - # 0 all - team: 0 - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # does not return imdb link in results - - rows: - selector: table.ttable_headinner tr.t-row - - fields: - category_optional: - selector: a[href*="cat="] - attribute: href - optional: true - filters: - - name: querystring - args: cat - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}56{{ end }}" - title: - selector: a[href^="torrents-details.php?id="] - attribute: title - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - filters: - - name: replace - args: ["&hit=1", ""] # avoid redirect - download: - selector: a[href^="torrents-details.php?id="] - attribute: href - filters: - - name: replace - args: ["&hit=1", ""] # avoid redirect - size: - selector: td:nth-last-child(7) - date: - selector: td:nth-last-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd-MM-yyyyHH:mm:ss" - grabs: - selector: td:nth-last-child(5) - seeders: - selector: td:nth-last-child(4) - leechers: - selector: td:nth-last-child(3) - description: - case: - img[src="pic/tick.png"]: Verified - img[src="pic/unchecked.png"]: Unverified - downloadvolumefactor: - case: - img[src="pic/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 10 day (as seconds = 10 x 24 x 60 x 60) - text: 864000 -# TorrentTrader v2-svn diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellenic-hd-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellenic-hd-api.yml deleted file mode 100644 index e338c2b5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hellenic-hd-api.yml +++ /dev/null @@ -1,190 +0,0 @@ ---- -id: hellenic-hd-api -name: HELLENIC-HD (API) -description: "HELLENIC-HD is a GREEK Private Torrent Tracker for MOVIES / TV" -language: el-GR -type: private -encoding: UTF-8 -links: - - https://hellenic-hd.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your HELLENIC-HD account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts that have not logged in for 60 days will be disabled, and may be deleted shortly afterwards." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 14 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 14 days (as seconds = 14 x 24 x 60 x 60) - text: 1209600 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/helltorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/helltorrents.yml deleted file mode 100644 index 44b1320a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/helltorrents.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: helltorrents -name: HellTorrents -description: "HellTorrents is a POLISH Semi-Private PAY2DL Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: semi-private -encoding: ISO-8859-2 -links: - - https://helltorrents.com/ - -caps: - categorymappings: - # Filmy - - {id: 30, cat: Movies/SD, desc: "Filmy DivX/XviD"} - - {id: 27, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 72, cat: Movies/BluRay, desc: "Filmy BluRay/HDTV 1080p"} - - {id: 73, cat: Movies/BluRay, desc: "Filmy BluRay/HDTV 720p"} - - {id: 74, cat: Movies/HD, desc: "Filmy x265/h265"} - - {id: 75, cat: Movies/HD, desc: "Filmy x264/h264"} - - {id: 76, cat: Movies/HD, desc: "Filmy HD 1080p AVC"} - - {id: 77, cat: Movies/UHD, desc: "Filmy i Seriale 4K UHD"} - - {id: 71, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 31, cat: Movies/HD, desc: "Filmy RMVB"} - - {id: 28, cat: Movies/SD, desc: "Filmy VCD/SVCD"} - - {id: 78, cat: Movies/SD, desc: "Filmy IVO"} - - {id: 79, cat: Movies/SD, desc: "Filmy TS/CAM"} - # Seriale - - {id: 25, cat: TV, desc: "Seriale Polskie"} - - {id: 26, cat: TV/Foreign, desc: "Seriale Zagraniczne"} - # Anime - - {id: 69, cat: TV/Anime, desc: "Anime PL"} - - {id: 70, cat: TV/Anime, desc: "Anime"} - # Dla Dzieci - - {id: 32, cat: Movies, desc: "Bajki Pl/Eng"} - # Muzyka - - {id: 80, cat: Audio, desc: "Muzyka Polska"} - - {id: 38, cat: Audio/Foreign, desc: "Muzyka Zagraniczna"} - - {id: 39, cat: Audio, desc: "Soundtracki"} - - {id: 40, cat: Audio/Video, desc: "Teledyski"} - - {id: 81, cat: Audio/Video, desc: "Koncerty"} - # Gry - - {id: 34, cat: PC/Games, desc: "Gry PC"} - - {id: 36, cat: Console/XBox, desc: "Xbox One/XBOX 360/Xbox"} - - {id: 37, cat: Console/NDS, desc: "NINTENDO / WII / DS"} - - {id: 35, cat: Console/PSP, desc: "PS2/PS3/PS4/PSP"} - # GSM/PDA - - {id: 42, cat: PC/Mobile-Other, desc: "Tapety GSM/PDA"} - - {id: 43, cat: PC/Mobile-Other, desc: "Programy GSM/PDA"} - - {id: 44, cat: PC/Mobile-Other, desc: "Filmy GSM/PDA"} - - {id: 45, cat: PC/Mobile-Other, desc: "Dzwonki GSM/PDA"} - - {id: 46, cat: PC/Mobile-Other, desc: "Gry GSM/PDA"} - # Ksiazki - - {id: 47, cat: Books/EBook, desc: "E-Booki"} - - {id: 48, cat: Audio/Audiobook, desc: "Audio-Booki"} - - {id: 49, cat: Books/Mags, desc: "Czasopisma"} - - {id: 50, cat: Books/Comics, desc: "Komiksy"} - # Programy - - {id: 51, cat: PC/0day, desc: "Windows"} - - {id: 52, cat: PC, desc: "Linux"} - - {id: 53, cat: PC/Mac, desc: "Macintosh"} - # Inne - - {id: 54, cat: Other, desc: "Śmieszne"} - - {id: 55, cat: Other, desc: "Tapety"} - - {id: 56, cat: TV/Sport, desc: "Sport"} - - {id: 57, cat: Other, desc: "Pozostale"} - # Erotyka - - {id: 58, cat: XXX/Other, desc: "Czasopisma"} - - {id: 59, cat: XXX/ImageSet, desc: "Zdjęcia "} - - {id: 60, cat: XXX/x264, desc: "Filmy"} - - {id: 61, cat: XXX/Other, desc: "Gry "} - - {id: 62, cat: XXX/Other, desc: "Hentai+18"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentów na stronę: (Torrents per page) setting to 100 on your account profile. - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: login.php - method: form - form: form[name="login"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: font[color="#FF0000"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://helltorrents.com/torrents.php?search=2023&category=0&page=0 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - page: 0 - # does not support sorting results, or imdbid searching, or have imdb in results - error: - - selector: img[src="../images/bladpobierania.png"] - message: - text: "you do not have permission to download!" - - rows: - selector: table.header:has(a[href*="download.php?id="]), table.header:has(a[href*="piekielny.php"]) - - fields: - categorydesc: - selector: td.pole1 - filters: - - name: regexp - args: "Kategoria: (.+)" - title_phase1: - selector: a[href^="details.php?id="] - title_multilang: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="], a[href*="piekielny.php"] - attribute: href - poster: - selector: img - attribute: src - date: - selector: td.pole1 - filters: - - name: regexp - args: "Dodano: (\\d{2}/\\d{2}/\\d{4})" - - name: dateparse - args: "dd/MM/yyyy" - size: - selector: td.pole1 - filters: - - name: regexp - args: "Rozmiar: (\\d+\\.\\d+ [T|G|M|K|k]B)" - seeders: - selector: td.pole1 - filters: - - name: regexp - args: "Seedów: (\\d+)" - leechers: - selector: td.pole1 - filters: - - name: regexp - args: "Leecherów: (\\d+)" - grabs: - selector: td.pole1 - filters: - - name: regexp - args: "Ukończone: (\\d+)" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hhanclub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hhanclub.yml deleted file mode 100644 index cc010415..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hhanclub.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: hhanclub -name: HHanClub -description: "HHanClub is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -followredirect: true -links: - - https://hhanclub.net/ - - https://hhan.club/ -legacylinks: - - https://hhanclub.top/ - -caps: - categorymappings: - - {id: 409, cat: Other, desc: "其他"} - - {id: 407, cat: TV/Sport, desc: "Sports"} - - {id: 403, cat: TV, desc: "综艺"} - - {id: 402, cat: TV, desc: "电视剧"} - - {id: 405, cat: TV/Anime, desc: "动漫"} - - {id: 404, cat: TV/Documentary, desc: "纪录片"} - - {id: 401, cat: Movies, desc: "电影"} - - {id: 412, cat: Audio/Video, desc: "短剧"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the HHanClub Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Ultimate User and above will be retained forever
  2. Veteran Users and above will not be deleted after parking (in the control panel)
  3. Users with a parked account will be banned if they do not log in for 400 consecutive days
  4. Users who do not log in for 30 consecutive days will be banned
  5. Newly registered users with no traffic within 7 days, will be banned.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - ssl: yes - trackerssl: yes - error: - - selector: div#Container > div.flex:has(div:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - # required to bypass validation check - followredirect: true - inputs: - $raw: "{{ range .Categories }}cat[]={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - keywordsfilters: - # strip episode - - name: re_replace - args: ["\\b(S\\d{2,})?(?:E\\d{2,})\\b", "$1"] - - rows: - selector: div.w-full:has(a[href^="details.php?id="]) - - fields: - _episode_number: - selector: div.torrent-title > div > div - optional: true - filters: - - name: regexp - args: "第(\\d+(?:-\\d+)?)集" - category: - selector: a[href^="?cat[]="] - attribute: href - filters: - - name: querystring - args: cat[] - title: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["\\b(S\\d+)\\b", "{{ if .Result._episode_number }}$1E{{ .Result._episode_number }}{{ else }}$1{{ end }}"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: div.torrent-info-text:nth-child(2) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: div.torrent-info-text:nth-child(2):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: div.torrent-info-text:nth-child(1) - seeders: - selector: div.torrent-info-text:nth-child(3) - leechers: - selector: div.torrent-info-text:nth-child(4) - grabs: - selector: div.torrent-info-text:nth-child(5) - downloadvolumefactor: - case: - span.promotion-tag-free: 0 - span.promotion-tag-free2up: 0 - span.promotion-tag-50pctdown: 0.5 - span.promotion-tag-50pctdown2up: 0.5 - span.promotion-tag-30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - span.promotion-tag-50pctdown2up: 2 - span.promotion-tag-free2up: 2 - span.promotion-tag-2up: 2 - "*": 1 -# global MR is 0.8 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.8 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: div.torrent-title > div > div -# NexusPHP v1.8.6 2023-08-15 (customised) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hhd-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hhd-api.yml deleted file mode 100644 index e48c472c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hhd-api.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: hhd-api -name: HHD (API) -description: "HHD is a Private Torrent Tracker for MOVIES / TV / GAMES" -language: en-US -type: private -encoding: UTF-8 -links: - - https://homiehelpdesk.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: TV/Anime, desc: "Anime"} - - {id: 4, cat: Audio, desc: "Music"} - - {id: 5, cat: Console, desc: "Games"} - - {id: 6, cat: PC, desc: "Apps"} - - {id: 7, cat: Books/Technical, desc: "Books"} - - {id: 8, cat: Audio/Audiobook, desc: "Audiobook"} - - {id: 11, cat: Books/Comics, desc: "Comics"} - - {id: 12, cat: Books/Mags, desc: "Magazine"} - - {id: 9, cat: Books/Comics, desc: "Manga"} - - {id: 10, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your HHD account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To not get disabled, you are required to login to the site at least once every 30 days or be actively seeding at least 2 torrent." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/houseofdevil.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/houseofdevil.yml deleted file mode 100644 index 59b4638f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/houseofdevil.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: houseofdevil -name: House of Devil -description: "House of Devil is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://h-o-d.org/ - -caps: - categorymappings: - - {id: 117, cat: TV, desc: "Allgemein - Serien"} - - {id: 119, cat: Other, desc: "Bilder"} - - {id: 120, cat: Books, desc: "EBook"} - - {id: 138, cat: Movies/3D, desc: "Filme - 3D"} - - {id: 101, cat: TV/Anime, desc: "Filme - Animie"} - - {id: 102, cat: Movies/BluRay, desc: "Filme - Bluray"} - - {id: 131, cat: Movies/Other, desc: "Filme - Bollywood"} - - {id: 103, cat: Movies/DVD, desc: "Filme - DVD"} - - {id: 104, cat: Movies/DVD, desc: "Filme - DVD-R"} - - {id: 135, cat: Movies/HD, desc: "Filme - HD"} - - {id: 132, cat: Movies/DVD, desc: "Filme - HD2DVD"} - - {id: 130, cat: Movies, desc: "Filme - Klassiker"} - - {id: 134, cat: Movies/SD, desc: "Filme - SD"} - - {id: 136, cat: Movies/UHD, desc: "Filme - UHD"} - - {id: 105, cat: Movies/HD, desc: "Filme - x264"} - - {id: 106, cat: Movies/SD, desc: "Filme - XviD / DivX"} - - {id: 127, cat: Movies, desc: "Fuer Unsere Kleinsten"} - - {id: 121, cat: PC/Mobile-Other, desc: "Handy-Stuff"} - - {id: 124, cat: Audio, desc: "Musik - Alben"} - - {id: 141, cat: Audio, desc: "Musik - Diskografie"} - - {id: 122, cat: Audio/Audiobook, desc: "Musik - Hoerbuch"} - - {id: 123, cat: Audio, desc: "Musik - Mixe"} - - {id: 133, cat: Audio/MP3, desc: "Musik - MP3"} - - {id: 125, cat: Audio/Video, desc: "Musik - Video"} - - {id: 113, cat: PC, desc: "Programme - Linux"} - - {id: 114, cat: PC/Mac, desc: "Programme - Mac"} - - {id: 115, cat: PC/0day, desc: "Programme - Windows"} - - {id: 116, cat: TV/Documentary, desc: "Serien - Dokus"} - - {id: 118, cat: TV/Sport, desc: "Serien - Sport"} - - {id: 142, cat: TV, desc: "Serien - Pack"} - - {id: 129, cat: Other, desc: "Sonstiges"} - - {id: 109, cat: PC/Mobile-Other, desc: "Spiele - Handy"} - - {id: 112, cat: Console, desc: "Spiele - Konsolen"} - - {id: 111, cat: PC/Games, desc: "Spiele - Mac / Linux"} - - {id: 110, cat: PC/Games, desc: "Spiele - PC"} - - {id: 137, cat: Console/Other, desc: "Wimmelspiele"} - - {id: 69, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: onlyupload - type: checkbox - label: Filter OnlyUpload only - default: false - - name: info_free - type: info - label: About OnlyUpload at H-O-D - default: "
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Prowlarr search results page these are tagged as Freeleech).
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents pro Seite: setting to 100 on your UserCP page. The default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "If you are inactive for longer than 8 weeks, your account will be automatically deactivated." - - name: info_seedhours - type: info - label: Seeding Hours - default: Before using this indexer, make sure that you have set Seed-Angaben and DSL Speed on your UserCP page. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - pin: "{{ .Config.pin }}" - error: - - selector: .tablea:contains("Fehler") - test: - path: / - selector: a[href="logout.php"] - -search: - # https://h-o-d.org/browse.php?showsearch=1&c117=1&c116=1&c118=1&search=&incldead=1&orderby=added&sort=desc - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - showsearch: 1 - search: "{{ .Keywords }}" - # 0 active, 1 all, 2 dead - incldead: 1 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # site does not support imdbid search from the browse.php page or return imdb links in results - - rows: - selector: "table.tableinborder[cellspacing=\"1\"][cellpadding=\"0\"]:not(:has(a[href=\"index.php\"])) > tbody > tr:has(a[href^=\"download.php\"]){{ if .Config.onlyupload }}:has(img[src=\"/pic/free.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php"] - details: - selector: a[href^="details.php"] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - poster: - selector: a[href^="details.php"] - attribute: onMouseOver - filters: - - name: regexp - args: "src=(.*?) width" - size: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(1) b:nth-child(1) - files: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(1) b:nth-child(2) - seeders: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(2) b:nth-child(1) - leechers: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(2) > b:nth-of-type(2) - grabs: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(3) b:nth-child(1) - date: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(5) - filters: - - name: append - args: " +01:00" # CET - - name: replace - args: ["\xA0", " "] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="/pic/free.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.8 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 -# NetVision diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hqmusic.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hqmusic.yml deleted file mode 100644 index a8eff599..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hqmusic.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: hqmusic -name: HQMusic -description: "HQMusic is a VIETNAMESE Private Torrent Tracker for MUSIC" -language: vi-VN -type: private -encoding: UTF-8 -links: - - https://tracker.hqmusic.info/ - -caps: - categorymappings: - - {id: 1, cat: Audio, desc: "Music"} - - modes: - search: [q] - music-search: [q, album, artist] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts will be deleted according to the following rules:
  1. Power Users and above will never have their accounts deleted.
  2. Users and above will never have their accounts deleted if they park their accounts (See [User CP]).
  3. Parked accounts will be deleted if they do not log in within 365 days.
  4. Unparked accounts will be deleted if they do not log in within 240 days.
  5. Inactive accounts downloading or uploading on the tracker will be deleted after 240 days of not logging in or after 120 days from the time of registration.
" - -login: - # switched from the form method to the cookie method due to https://github.com/Prowlarr/Prowlarr/issues/792 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://tracker.hqmusic.info/torrents.php?search=&search_area=0&search_mode=0&incldead=0&haslog=0&cue=0&cover=0&tracks=0&spstate=0 - - path: torrents.php - inputs: - search: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ end }}{{ if .Query.Album }} {{ .Query.Album }}{{ else }}{{ end }}{{ if or .Query.Artist .Query.Album }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 has log, 1 yes, 2 no, 3 100% only, 4 100% Unscored - haslog: 0 - # 0 cue, 1 yes, 2 no - cue: 0 - # 0 cover, 1 yes, 2 no - cover: 0 - # tracks, 1 yes, 2 no - tracks: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.main > tbody > tr > td.embedded > table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - text: 1 - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[src^="imagescover"] - attribute: src - filters: - - name: prepend - args: "https://images.weserv.nl/?url={{ .Config.sitelink }}" # for display on dashboard - - name: append - args: "&w=180&h=270" # for display on dashboard - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.4 - minimumseedtime: - # 60 hours (as seconds = 60 x 60 x 60) - text: 216000 - description: - selector: td:nth-child(1) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hudbt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hudbt.yml deleted file mode 100644 index cf864043..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hudbt.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -id: hudbt -name: HUDBT (蝴蝶) -description: "HUDBT (蝴蝶) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://hudbt.hust.edu.cn/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "大陆电影"} - - {id: 413, cat: Movies, desc: "港台电影"} - - {id: 414, cat: Movies, desc: "亚洲电影"} - - {id: 415, cat: Movies, desc: "欧美电影"} - - {id: 430, cat: PC/Mobile-iOS, desc: "iPad"} - - {id: 433, cat: Movies, desc: "抢先视频"} - - {id: 402, cat: TV, desc: "大陆剧集"} - - {id: 417, cat: TV, desc: "港台剧集"} - - {id: 416, cat: TV, desc: "亚洲剧集"} - - {id: 418, cat: TV, desc: "欧美剧集"} - - {id: 404, cat: TV/Documentary, desc: "纪录片"} - - {id: 407, cat: TV/Sport, desc: "体育"} - - {id: 403, cat: TV, desc: "大陆综艺"} - - {id: 419, cat: TV, desc: "港台综艺"} - - {id: 420, cat: TV, desc: "亚洲综艺"} - - {id: 421, cat: TV, desc: "欧美综艺"} - - {id: 408, cat: Audio, desc: "华语音乐"} - - {id: 422, cat: Audio, desc: "日韩音乐"} - - {id: 423, cat: Audio, desc: "欧美音乐"} - - {id: 424, cat: Audio, desc: "古典音乐"} - - {id: 425, cat: Audio, desc: "原声音乐"} - - {id: 406, cat: Audio/Video, desc: "音乐MV"} - - {id: 409, cat: Other/Misc, desc: "其他"} - - {id: 432, cat: Books/EBook, desc: "电子书"} - - {id: 405, cat: TV/Anime, desc: "完结动漫"} - - {id: 427, cat: TV/Anime, desc: "连载动漫"} - - {id: 428, cat: TV/Anime, desc: "剧场OVA"} - - {id: 429, cat: TV/Anime, desc: "动漫周边"} - - {id: 410, cat: PC/Games, desc: "游戏"} - - {id: 431, cat: Console, desc: "游戏视频"} - - {id: 411, cat: PC/0day, desc: "软件"} - - {id: 412, cat: Books, desc: "学习"} - - {id: 426, cat: PC/Mac, desc: "MAC"} - - {id: 1037, cat: Other, desc: "HUST"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not be deleted after parking (in the control panel)
  3. Users with a parked account who do not log in for 240 consecutive days will be deleted
  4. Users with non-parked account will be deleted if they do not log in for 120 consecutive days
  5. Users with no traffic (that is, upload/download data are both 0) will be deleted if they do not log in for 30 consecutive days, or if the registration time reaches 60 days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: div#stderr:has(h2:contains("失败")) - test: - path: index.php - selector: form[action$="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 no limit, 3 3days, 7 1week, 30 1month, 90 3months - indate: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30%, 8 special offer, 9 all promotions - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(div.torrent-title) - - fields: - category: - selector: a[href*="cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.free: 0 - img.twoupfree: 0 - img.halfdown: 0.5 - img.twouphalfdown: 0.5 - img.thirtypercent: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.twouphalfdown: 2 - img.twoupfree: 2 - img.twoup: 2 - "*": 1 - description: - selector: td:nth-child(2) div.torrent-title h3 -# NexusPHP HUDBT自优化版本 2013-05-13 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/huntorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/huntorrent.yml deleted file mode 100644 index cfa568f7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/huntorrent.yml +++ /dev/null @@ -1,183 +0,0 @@ ---- -id: huntorrent -replaces: - - huntorrent-bithorlo -name: HunTorrent -description: "HunTorrent is a HUNGARIAN Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: semi-private -encoding: UTF-8 -requestDelay: 2 -links: - - https://huntorrent.org/ -legacylinks: - - https://huntorrent.net/ - - https://huntorrent.eu/ - -caps: - categorymappings: - - {id: 28, cat: Movies/SD, desc: "Cam/Külföldi", default: true} - - {id: 27, cat: Movies/SD, desc: "Cam/Magyar", default: true} - - {id: 4, cat: Movies/DVD, desc: "DVDR/Külföldi", default: true} - - {id: 3, cat: Movies/DVD, desc: "DVDR/Magyar", default: true} - - {id: 6, cat: Movies/HD, desc: "HD/Külföldi", default: true} - - {id: 5, cat: Movies/HD, desc: "HD/Magyar", default: true} - - {id: 2, cat: Movies/SD, desc: "XviD/Külföldi", default: true} - - {id: 1, cat: Movies/SD, desc: "XviD/Magyar", default: true} - - {id: 17, cat: PC/Games, desc: "Játék/ISO", default: true} - - {id: 18, cat: PC/Games, desc: "Játék/RIP", default: true} - - {id: 19, cat: Console, desc: "Konzol", default: true} - - {id: 25, cat: Other, desc: "Kép", default: true} - - {id: 24, cat: Books, desc: "eBook/Külföldi", default: true} - - {id: 23, cat: Books, desc: "eBook/Magyar", default: true} - - {id: 38, cat: Audio/Audiobook, desc: "Hangoskönyv", default: true} - - {id: 26, cat: PC/Mobile-Other, desc: "Mobil", default: true} - - {id: 20, cat: PC/ISO, desc: "Program/ISO", default: true} - - {id: 21, cat: PC/0day, desc: "Program/RIP", default: true} - - {id: 31, cat: TV/HD, desc: "Sorozat/HD/Külföldi", default: true} - - {id: 9, cat: TV/HD, desc: "Sorozat/HD/Magyar", default: true} - - {id: 8, cat: TV/SD, desc: "Sorozat/Külföldi", default: true} - - {id: 7, cat: TV/SD, desc: "Sorozat/Magyar", default: true} - - {id: 13, cat: Audio/Video, desc: "Klip/Külföldi", default: true} - - {id: 12, cat: Audio/Video, desc: "Klip/Külföldi", default: true} - - {id: 11, cat: Audio/MP3, desc: "MP3/Külföldi", default: true} - - {id: 10, cat: Audio/MP3, desc: "MP3/Magyar", default: true} - - {id: 16, cat: XXX/DVD, desc: "XXX/DVD", default: false} - - {id: 32, cat: XXX/x264, desc: "XXX/HD", default: false} - - {id: 14, cat: XXX/ImageSet, desc: "XXX/Kép", default: false} - - {id: 15, cat: XXX/SD, desc: "XXX/XVID", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - method: cookie - # using cookie method because login page has embedded Google reCAPTCHA - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: browse/ - selector: a[href*="logout.php?k="] - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - paths: - # https://huntorrent.net/browse/?korhatar=0&incldead=1&xyz=yes&c28=1&c27=1&c6=1&c5=1&sort=4&type=desc - - path: browse/ - categories: [28, 27, 4, 3, 6, 5, 2, 1, 17, 18, 19, 25, 24, 23, 38, 26, 20, 21, 31, 9, 8, 7, 13, 12, 11, 10] - inputs: - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - - path: browse/xxx - categories: [16, 32, 14, 15] - inputs: - search: "{{ .Keywords }}" - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - xyz: yes - # Age Limit: 0 all, 1 no, 5 6, 2 12, 3 16, 4 18 - korhatar: 0 - # 0 active, 1 active+dead, 2 dead, 3 my uploads, 4 waiting for seed, 7 my bookmarks, 12 reseeded by me - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table.torrent-table > tbody > tr.torrent-row - - fields: - category: - selector: a[href*="/?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a.torrent-title-link - attribute: data-name - details: - selector: a.torrent-title-link - attribute: data-detail-url - download: - selector: a[href^="/download/"] - attribute: href - genre: - selector: a[data-mufaj] - attribute: data-mufaj - description: - selector: div.torrent-subtitle - filters: - - name: append - args: "{{ if .Result.genre }}
{{ .Result.genre }}{{ else }}{{ end }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: a[data-poster] - attribute: data-poster - filters: - - name: prepend - args: "/" - date: - selector: a[data-added] - attribute: data-added - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm zzz" - size: - selector: a[data-size] - attribute: data-size - grabs: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 2 - minimumratio: - text: 1.0 - minimumseedtime: - # 32 hours (as seconds = 32 x 60 x 60) - text: 115200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hxpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hxpt.yml deleted file mode 100644 index 3293c1fa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/hxpt.yml +++ /dev/null @@ -1,168 +0,0 @@ ---- -id: hxpt -name: HxPT (好学) -description: "HxPT (好学) is a CHINESE Private Torrent Tracker for E-LEARNING" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.hxpt.org/ - -caps: - categorymappings: - - {id: 401, cat: Other, desc: "学前教育 (Preschool)"} - - {id: 402, cat: Other, desc: "小学部 (Primary School)"} - - {id: 403, cat: Other, desc: "初中部 (Middle School)"} - - {id: 404, cat: Other, desc: "高职部 (Vocational High School)"} - - {id: 405, cat: Other, desc: "高中部 (High School)"} - - {id: 406, cat: Other, desc: "教育影音 (Educational Video)"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 24 hours (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ilcorsaroblu.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ilcorsaroblu.yml deleted file mode 100644 index 08ae3211..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ilcorsaroblu.yml +++ /dev/null @@ -1,262 +0,0 @@ ---- -id: ilcorsaroblu -name: Il Corsaro Blu -description: "il CorSaRo Blu is an ITALIAN Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: semi-private -encoding: UTF-8 -links: - - https://ilcorsaroblu.org/ -legacylinks: - - http://ilcorsaroblu.org/ - - https://www.ilcorsaroblu.info/ - - https://www.ilcorsaroblu.org/ - - https://ilcorsaroblu.online/ - -caps: - categorymappings: - # Adult - - {id: 12, cat: XXX, desc: "Adult"} - # Applicazioni - - {id: 5, cat: PC/Mobile-Android, desc: "Android"} - - {id: 30, cat: PC/Mobile-iOS, desc: "iOS / iPhone"} - - {id: 7, cat: PC/0day, desc: "Windows"} - - {id: 8, cat: PC, desc: "Linux"} - - {id: 9, cat: PC/Mac, desc: "Mac"} - - {id: 40, cat: PC, desc: "Emulatori"} - - {id: 34, cat: PC, desc: "Student's Office"} - # Books - - {id: 6, cat: Books/EBook, desc: "Books"} - - {id: 45, cat: Books/Comics, desc: "Fumetti"} - - {id: 51, cat: Books/EBook, desc: "Pdf"} - - {id: 33, cat: Books/EBook, desc: "Romanzi"} - - {id: 26, cat: Books/Mags, desc: "Edicola: Giornali/Quotidiani"} - # Games - - {id: 3, cat: PC/Games, desc: "Games PC"} - - {id: 27, cat: Console, desc: "Games Console"} - - {id: 41, cat: Console/XBox, desc: "Games Xbox360"} - - {id: 43, cat: Console, desc: "Games Retro"} - - {id: 47, cat: Console/NDS, desc: "Games Nintendo"} - # Music - - {id: 2, cat: Audio, desc: "Music"} - - {id: 48, cat: Audio/MP3, desc: "Music MP3"} - - {id: 49, cat: Audio, desc: "Radio Trasmissioni"} - # Movies - - {id: 17, cat: Movies/HD, desc: "Movie BDRip"} - - {id: 39, cat: Movies/HD, desc: "Movie BDRip TNT"} - - {id: 21, cat: Movies/Other, desc: "Movie Films"} - - {id: 11, cat: Movies/DVD, desc: "Movie DVD-R"} - - {id: 14, cat: Movies/HD, desc: "Movie 720p"} - - {id: 13, cat: Movies/HD, desc: "Movie 1080p"} - - {id: 15, cat: Movies/3D, desc: "Movie 3D"} - - {id: 25, cat: Movies/UHD, desc: "Movie UHD"} - - {id: 36, cat: Movies, desc: "Movie TNT"} - - {id: 24, cat: TV/SD, desc: "TV Show Standard"} - - {id: 19, cat: TV/HD, desc: "TV Show 1080p"} - - {id: 20, cat: TV/HD, desc: "TV Show 720"} - - {id: 50, cat: TV, desc: "TV Show TNT"} - - {id: 28, cat: TV/Anime, desc: "Anime"} - - {id: 29, cat: TV, desc: "Cartoons"} - - {id: 37, cat: TV/Sport, desc: "Sport"} - - {id: 35, cat: TV/Documentary, desc: "Documentari"} - - {id: 38, cat: Movies, desc: "Commedia"} - - {id: 46, cat: Movies, desc: "Movie Parodie"} - # Various - - {id: 4, cat: Other, desc: "Other"} - - {id: 23, cat: Other, desc: "Archive"} - - {id: 42, cat: Other, desc: "Disegni e Modelli"} - - {id: 16, cat: Other, desc: "V.I.P."} - - {id: 32, cat: Other, desc: "Premium"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: info_flaresolverr - type: info_flaresolverr - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: index.php?page=login&returnto=index.php - method: form - form: form[name="login"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: index.php - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to 1 - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace # S01E01 to 1 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1 $2"] - # https://www.ilcorsaroblu.org/index.php?page=torrents&search=&category=0&options=0&active=0 - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}" - # 0 title, 1 title & descr, 2 descr, 3 uploaders, 5 gold, 6 silver, 7 bronze - # note the options are exclusive, so searching for gold means the search keywords are ignored - options: 0 - # 0 all, 1 activeonly, 2 deadonly - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - # does not support imdbid searches and does not return imdb link in results - - rows: - selector: "div.b-content > table > tbody > tr > td > table.lista > tbody > tr:has(a[href^=\"index.php?page=torrents&category=\"]){{ if .Config.freeleech }}:has(img[src$=\"gold.gif\"]){{ else }}{{ end }}" - filters: - - name: andmatch - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: td:nth-child(2) > a - filters: - - name: re_replace # replace special characters with " " (space) - args: ["[\\[!\"#$%&'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]", " "] - - name: diacritics - args: replace - - name: re_replace # replace multiple spaces - args: ["[ ]{2,}", " "] - # normalize to SXXEYY format - - name: re_replace # S01 E01 to S01E01 - args: ["(?i)\\bS(\\d+)\\sE(\\d+)\\b", "S$1E$2"] - - name: re_replace # 01x01 to S01E01 - args: ["(?i)(\\d{2})x(\\d+)", "S$1E$2"] - - name: re_replace # 1x01 to S01E01 - args: ["(?i)\\b(\\d{1})x(\\d+)", "S0$1E$2"] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - - name: re_replace # Episodio 4 to E4 - args: ["(?i)\\b(?:[\\/\\|]?Episodio\\s?(\\d+)|Puntata\\s?(\\d+))", "E$1$2"] - - name: re_replace # Episodi 4 5 to E04-05 - args: ["(?i)\\b(?:Puntate\\s*)(\\d+)\\s?(\\d+)", "E0$1-0$2"] - - name: re_replace # rimozioni varie - args: ["(?i)(Serie completa|Completat?a?|in pausa)", ""] - # cleanup for Sonarr - - name: re_replace # EP 3 4 to E3-4 - args: ["(?i)\\sEP\\s(\\d{1,2})\\s(E?\\s?\\d{1,2})\\s", " E$1-$2 "] - - name: re_replace # S02E04 05 to S02E04-05 - args: ["(?i)\\sS(\\d{1,2})\\s?E\\s?(\\d{1,2})\\s(E?\\s?\\d{1,2})\\s", " S$1E$2-$3 "] - - name: re_replace - args: ["(?i)AC3\\s?(\\d)\\s(\\d)", "AC3 $1.$2"] - - name: re_replace - args: ["(?i) DD\\s?(\\d)\\s(\\d)", " DD $1.$2"] - - name: re_replace - args: ["(?i) DDP\\s?(\\d)\\s(\\d)", " DDP $1.$2"] - - name: re_replace - args: ["(?i)\\sE\\s?AC3", " EAC3"] - - name: re_replace - args: ["(?i)WEB\\sDL", "WEB-DL"] - - name: re_replace - args: ["(?i)HDTVRIP", "HDTV"] - details: - selector: td:nth-child(2) > a - attribute: href - download: - optional: true - selector: a[href^="download.php?id="] - attribute: href - filters: - - name: querystring - args: id - - name: toupper - - name: prepend - args: http://itorrents.org/torrent/ - - name: append - args: ".torrent" - infohash: - optional: true - selector: a[href^="download.php?id="] - attribute: href - filters: - - name: querystring - args: id - magnet: - optional: true - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-last-child(2) - date_ago: - selector: td:nth-last-child(7):contains('ago') - # auto adjusted by site account profile - optional: true - filters: - - name: timeago - date_year: - selector: td:nth-last-child(7):contains('/') - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "dd/MM/yyyy" - date: - text: "{{ if or .Result.date_ago .Result.date_year }}{{ or .Result.date_ago .Result.date_year }}{{ else }}now{{ end }}" - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(6) - leechers: - selector: td:nth-last-child(5) - downloadvolumefactor: - case: - img[src="images/gold.gif"]: 0 - img[src="images/silver.gif"]: 0.5 - img[src="images/bronze.gif"]: 0.75 - "*": 1 - uploadvolumefactor: - text: 1 -# minimumratio: appears to be ratioless -# xbtit diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ilcorsaronero.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ilcorsaronero.yml deleted file mode 100644 index aa44e929..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ilcorsaronero.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: ilcorsaronero -name: ilCorSaRoNeRo -description: "ilCorSaRoNeRo is an ITALIAN Public site for MOVIES / TV / GENERAL" -language: it-IT -type: public -encoding: UTF-8 -# current (official?) domains list https://loiflix.shop/ilcorsaronero/ -links: - - https://ilcorsaronero.link/ - - https://ilcorsaronero.torrentbay.st/ -legacylinks: - - https://ilcorsaronero.live/ - - https://ilcorsaronero.vip/ - - https://ilcorsaronero.info/ - - https://ilcorsaronero.ch/ - - https://ilcorsaronero.cc/ - - https://ilcorsaronero.pizza/ - - https://ilcorsaronero.pw/ - - https://ilcorsaronero.xyz/ - - https://ilcorsaronero.torrentbay.to/ - - https://ilcorsaronero.fun/ - - https://ilcorsaronero.pro/ - - https://ilcorsaronero.torrentbay.net/ - - https://ilcorsaronero.mrunblock.bond/ - - https://ilcorsaronero.nocensor.cloud/ - -caps: - categorymappings: - - {id: film, cat: Movies, desc: "Film"} - - {id: serie-tv, cat: TV, desc: "Serie TV"} - - {id: animazione, cat: TV/Anime, desc: "Animazione"} - - {id: animazione-film, cat: TV/Anime, desc: "Animazione - Film"} - - {id: animazione-serie, cat: TV/Anime, desc: "Animazione - Serie"} - - {id: musica, cat: Audio, desc: "Musica"} - - {id: musica-audio, cat: Audio, desc: "Musica - Audio"} - - {id: musica-video, cat: Audio/Video, desc: "Musica - Video Musicali"} - - {id: libri, cat: Books, desc: "Libri"} - - {id: libri-ebook, cat: Books/EBook, desc: "Libri - eBook"} - - {id: libri-audio, cat: Audio/Audiobook, desc: "Libri - Audiolibri"} - - {id: libri-fumetti, cat: Books/Comics, desc: "Libri - Fumetti"} - - {id: libri-edicola, cat: Books/Mags, desc: "Libri - Edicola"} - - {id: giochi, cat: PC/Games, desc: "Giochi"} - - {id: giochi-pc, cat: PC/Games, desc: "Giochi - PC"} - - {id: giochi-console, cat: PC/Games, desc: "Giochi - Console"} - - {id: software, cat: PC, desc: "Software"} - - {id: software-windows, cat: PC/0day, desc: "Software - Windows"} - - {id: software-macos, cat: PC/Mac, desc: "Software - macOS"} - - {id: software-linux, cat: PC, desc: "Software - Linux"} - - {id: software-mobile, cat: PC/Mobile-Other, desc: "Software - Mobile"} - - {id: altro, cat: Other, desc: "Altro"} - - {id: altro-docu, cat: Other, desc: "Altro - Documentari"} - - {id: altro-tv, cat: Other, desc: "Altro - Programmi TV"} - - {id: altro-corsi, cat: Other, desc: "Altro - Videocorsi"} - - {id: altro-altro, cat: Other, desc: "Altro - Altro"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: category-id - type: select - label: Category (for Keyword search) - default: all - options: - all: "All categories" - film: "Film" - serie-tv: "Serie TV" - animazione: "Animazione" - musica: "Musica" - libri: "Libri" - giochi: "Giochi" - software: "Software" - altro: "Altro" - - name: dl_type - type: select - label: Download type - default: Magnet - options: - Magnet: "magnet" - Torrent: "torrent" - -download: - selectors: - - selector: a:has(span:contains("{{ .Config.dl_type }}")) - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}search{{ else }}{{ end }}" - inputs: - q: "{{ .Keywords }}" - cat: "{{ if .Keywords }}{{ re_replace .Config.category-id \"all\" \"\" }}{{ else }}{{ end }}" - - rows: - selector: table > tbody > tr:has(a[href^="/torrent/"]) - filters: - - name: andmatch - - fields: - categorydesc: - selector: td:nth-child(1) - title: - selector: a[href^="/torrent/"] - filters: - - name: re_replace - args: ["\\.", " "] - # normalize to SXXEYY format - - name: re_replace # S01 E01 to S01E01 - args: ["(?i)\\bS(\\d+)\\sE(\\d+)\\b", "S$1E$2"] - - name: re_replace # 01x01 to S01E01 - args: ["(?i)(\\d{2})x(\\d+)", "S$1E$2"] - - name: re_replace # 1x01 to S01E01 - args: ["(?i)\\b(\\d{1})x(\\d+)", "S0$1E$2"] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - - name: re_replace # Episodio 4 to E4 - args: ["(?i)\\b(?:[\\/\\|]?Episodio\\s?(\\d+)|Puntata\\s?(\\d+))", "E$1$2"] - - name: re_replace # Episodi 4 5 to E04-05 - args: ["(?i)\\b(?:Puntate\\s*)(\\d+)\\s?(\\d+)", "E0$1-0$2"] - - name: re_replace # rimozioni varie - args: ["(?i)(Serie completa|Completat?a?|in pausa)", ""] - # cleanup for Sonarr - - name: re_replace # EP 3 4 to E3-4 - args: ["(?i)\\sEP\\s(\\d{1,2})\\s(E?\\s?\\d{1,2})\\s", " E$1-$2 "] - - name: re_replace # S02E04 05 to S02E04-05 - args: ["(?i)\\sS(\\d{1,2})\\s?E\\s?(\\d{1,2})\\s(E?\\s?\\d{1,2})\\s", " S$1E$2-$3 "] - - name: re_replace - args: ["(?i)AC3\\s?(\\d)\\s(\\d)", "AC3 $1.$2"] - - name: re_replace - args: ["(?i) DD\\s?(\\d)\\s(\\d)", " DD $1.$2"] - - name: re_replace - args: ["(?i) DDP\\s?(\\d)\\s(\\d)", " DDP $1.$2"] - - name: re_replace - args: ["(?i)\\sE\\s?AC3", " EAC3"] - - name: re_replace - args: ["(?i)WEB\\sDL", "WEB-DL"] - - name: re_replace - args: ["(?i)HDTVRIP", "HDTV"] - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="/torrent/"] - attribute: href - seeders: - selector: td:nth-child(3) - leechers: - selector: td:nth-child(4) - size: - selector: td:nth-child(5) - date: - # unix - selector: td:nth-child(6) - attribute: data-timestamp - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ildragonero.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ildragonero.yml deleted file mode 100644 index d6f804de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ildragonero.yml +++ /dev/null @@ -1,252 +0,0 @@ ---- -id: ildragonero -name: ilDraGoNeRo -description: "ilDraGoNeRo is an ITALIAN Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: semi-private -encoding: UTF-8 -links: - - https://ildragonero2.info/ - -caps: - # commented out categories are discussion-only forums - categorymappings: - # - {id: 10, cat: Other, desc: "..: REGOLAMENTO :.."} - # - {id: 2, cat: Other, desc: "..: PRESENTAZIONI :.."} - # - {id: 15, cat: Other, desc: "..: CAVERNA DEL DRAGO :.."} - # - {id: 97, cat: Other, desc: " ( AREA SONDAGGI )"} - # - {id: 44, cat: Other, desc: "..: SALA GIOCHI :.."} - # - {id: 1, cat: Other, desc: "[ RELEASES ]"} - # - {id: 35, cat: Other, desc: "..: GUIDE, ANNUNCI & REGOLE :.."} - # - {id: 19, cat: Movies, desc: "..: FILM :.."} - - {id: 52, cat: Movies, desc: " ( RELEASES IDN_CREW )", default: true} - - {id: 77, cat: Movies/SD, desc: " ( SD )", default: true} - - {id: 50, cat: Movies/HD, desc: " ( HD )", default: true} - - {id: 78, cat: Movies/UHD, desc: " ( 4K )", default: true} - - {id: 66, cat: Movies/Foreign, desc: " ( FILM SUB ITA )", default: true} - # - {id: 56, cat: Movies, desc: " ( FILMOGRAFIE )"} - # - {id: 12, cat: Movies, desc: " ( RICHIESTE )"} - # - {id: 47, cat: Movies, desc: " ( RICHIESTE EVASE )"} - - {id: 39, cat: XXX, desc: "..: FILM XXX :..", default: true} - # - {id: 32, cat: TV/Anime, desc: "..: CARTOON & ANIME :.."} - - {id: 63, cat: Movies/Other, desc: " ( CARTOON & ANIME FILM )", default: true} - - {id: 61, cat: TV/Anime, desc: " ( CARTOON & ANIME SERIE )", default: true} - - {id: 88, cat: TV/Anime, desc: " ( CARTOON & ANIME SUB ITA )", default: true} - # - {id: 80, cat: TV/Anime, desc: " ( RICHIESTE )"} - # - {id: 81, cat: TV/Anime, desc: " ( RICHIESTE EVASE )"} - # - {id: 23, cat: TV, desc: "..: SERIE TV :.."} - # - {id: 33, cat: TV, desc: " L'archivio di Pir8"} - - {id: 45, cat: TV/SD, desc: " (SD 480p)", default: true} - - {id: 46, cat: TV/HD, desc: " (HD 720p - 1080p - 2160p)", default: true} - - {id: 79, cat: TV/Foreign, desc: " ( SERIE TV SUB ITA )", default: true} - # - {id: 74, cat: TV, desc: " ( RICHIESTE )"} - # - {id: 75, cat: TV, desc: " ( RICHIESTE EVASE )"} - # - {id: 95, cat: Other, desc: " ( COMMENTI FILM E SERIE )"} - # - {id: 54, cat: TV/Documentary, desc: "..: DOCUMENTARI & SPETTACOLI :.."} - - {id: 92, cat: TV/Documentary, desc: " ( DOCUMENTARI )", default: true} - - {id: 53, cat: Audio/Video, desc: " ( CONCERTI )", default: true} - - {id: 93, cat: TV/Other, desc: " ( TV SHOW )", default: true} - # - {id: 85, cat: TV/Documentary, desc: " ( RICHIESTE )"} - # - {id: 86, cat: TV/Documentary, desc: " ( RICHIESTE EVASE )"} - # - {id: 8, cat: Audio, desc: "..: MUSICA :.."} - - {id: 41, cat: Audio/Foreign, desc: " ( ARTISTI STRANIERI )", default: true} - - {id: 42, cat: Audio, desc: " ( ARTISTI ITALIANI )", default: true} - - {id: 87, cat: Audio, desc: " ( COLONNE SONORE )", default: true} - - {id: 89, cat: Audio/Other, desc: " ( COMPILATION )", default: true} - # - {id: 57, cat: Audio, desc: " ( DISCOGRAFIE )"} - # - {id: 72, cat: Audio, desc: " ( RICHIESTE )"} - # - {id: 73, cat: Audio, desc: " ( RICHIESTE EVASE )"} - # - {id: 34, cat: PC/Games, desc: "..: VIDEOGIOCHI :.."} - - {id: 91, cat: PC/Games, desc: " ( VIDEOGIOCHI PER PC )", default: true} - - {id: 59, cat: PC/Games, desc: " ( MODDING )", default: true} - # - {id: 94, cat: PC/Games, desc: " ( COLLECTION )"} - # - {id: 70, cat: PC/Games, desc: " ( RICHIESTE )"} - # - {id: 71, cat: PC/Games, desc: " ( RICHIESTE EVASE )"} - # - {id: 36, cat: Books, desc: "..: SALA DI LETTURA :.."} - - {id: 67, cat: Books/EBook, desc: " ( E-BOOK )", default: true} - - {id: 82, cat: Books/Comics, desc: " ( FUMETTI )", default: true} - - {id: 62, cat: Audio/Audiobook, desc: " ( AUDIO-BOOKS )", default: true} - - {id: 38, cat: Books/Mags, desc: " ( QUOTIDIANI & PERIDOICI )", default: true} - # - {id: 68, cat: Books, desc: " ( RICHIESTE )"} - # - {id: 69, cat: Books, desc: " ( RICHIESTE EVASE )"} - # - {id: 9, cat: PC/0day, desc: "..: APPLICAZIONI :.."} - - {id: 20, cat: PC/0day, desc: " ( SOFTWARE WINDOWS )", default: true} - - {id: 96, cat: PC/0day, desc: " ( SISTEMI OPERATIVI WINDOWS )", default: true} - - {id: 21, cat: PC/Mac, desc: " ( SOFTWARE MAC )", default: true} - - {id: 22, cat: PC/Mobile-Android, desc: " ( APK ANDROID )", default: true} - # - {id: 83, cat: PC/0day, desc: " ( RICHIESTE )"} - # - {id: 84, cat: PC/0day, desc: " ( RICHIESTE EVASE )"} - # - {id: 30, cat: Other, desc: "..: RELEASES DA VERIFICARE :.."} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_delay - type: info - label: Delay between searches - default: This forum enforces a 15s delay between searches. If no results are returned for a search, wait for 15s and try again. - -login: - path: forum/ucp.php?mode=login - method: form - form: form#login - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - autologin: on - viewonline: on - error: - - selector: div.error - test: - path: forum/index.php - selector: a[href^="./ucp.php?mode=logout&sid="] - -download: - before: - # thankyou link: ./viewtopic.php?f=61&p=76522&thanks=76522&to_id=83&from_id=23462 - pathselector: - selector: ul.post-buttons li:last-child a - attribute: href - filters: - - name: re_replace - args: ["^.", "forum"] - selectors: - - selector: script:contains(addLinkToDocument) - filters: - - name: regexp - args: "addLinkToDocument\\(\"(.*?)\"" - - name: prepend - args: "magnet:?xt=urn:btih:" - -search: - paths: - - path: forum/search.php - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["\\b(?:[SE]\\d{1,4}){1,2}\\b", ""] - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - inputs: - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}*{{ end }}" - # all, any - terms: all - # Search in subforums: yes 1, no 0 - sc: 0 - # Search For: all, msgonly, titleonly, firstpost - sf: titleonly - # Show Results as: posts, topics - sr: topics - # sort: a author, t post time, f forum, i title, s post subject - sk: t - # order: a ascending, d descending - sd: d - # Seach Posts For: all 0, 1day 1, week 7, fortnight 14, month 30, 3months 90, 6 months 180, year 365 - st: 0 - # Show first: all -1, 0, 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000 - ch: 300 - # hidden - t: 0 - # hidden - submit: Cerca - $raw: "{{ range .Categories }}&fid[]={{.}}{{end}}" - - rows: - selector: li.row - - fields: - category: - selector: a[href^="./viewforum.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.topictitle - filters: - # normalize Stagione xx to Sxx format - - name: re_replace - args: ["(?i)\\bS(?:tagion[ei] )?(\\d{1,3}(?:-\\d{1,3})?)\\b\\s*(.+?)\\s*(?:\\[IN CORSO\\])?\\s*\\[(\\d{1,3}(?:-\\d{1,3})?)\\/(\\d{1,3})\\]", "S$1E$3 of $4 $2"] - - name: re_replace - args: ["(?i)\\bS(?:tagion[ei] )?(\\d{1,3}(?:-\\d{1,3})?)\\b\\s*(.+?)\\s*(?:\\[IN CORSO\\])?\\s*\\[(\\d{1,3}(?:-\\d{1,3})?)\\]", "S$1E$3 $2"] - - name: re_replace - args: ["(?i)\\bS(?:tagion[ei] )?(\\d{1,3}(?:-\\d{1,3})?)\\s+(E\\d{1,3}(?:-\\d{1,3})?)\\/(\\d{1,3})\\b", "S$1$2 of $3"] - - name: re_replace - args: ["(?i)\\bStagion[ei] (\\d{1,3}(?:-\\d{1,3})?)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\s*(?:\\[IN CORSO\\])?\\s*\\[(\\d{1,3}(?:-\\d{1,3})?)\\/(\\d{1,3})\\]", " E$1 of $2"] - details: - selector: a.topictitle - attribute: href - download: - selector: a.topictitle - attribute: href - poster: - selector: div[data-ot] - attribute: data-ot - filters: - - name: regexp - args: "src='(.*?)'" - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - date_day: - # » oggi, 00:12 » - # » ieri, 23:49 » - selector: div.responsive-hide.left-box:contains("oggi"), div.responsive-hide.left-box:contains("ieri") - remove: a - optional: true - filters: - - name: regexp - args: "» (.*?) »" - - name: re_replace - args: ["(?i)\\boggi\\b", "today"] - - name: re_replace - args: ["(?i)\\bieri\\b", "yesterday"] - - name: fuzzytime - date_time: - # » 26 minuti fa » - selector: div.responsive-hide.left-box:contains("fa") - remove: a - optional: true - filters: - - name: regexp - args: "» (.*?) »" - - name: re_replace - args: ["(?i)\\bminut[oi] fa\\b", "minutes ago"] - - name: timeago - date_year: - # » 04/11/2023, 7:05 » - selector: div.responsive-hide.left-box:contains("/") - remove: a - optional: true - filters: - - name: regexp - args: "» (.*?) »" - - name: append - args: " +00:00" # auto adjusted by site account profile - - name: dateparse - args: "dd/MM/yyyy, H:mm zzz" - date: - text: "{{ if or .Result.date_time .Result.date_year .Result.date_day }}{{ or .Result.date_time .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a.topictitle -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/immortuos.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/immortuos.yml deleted file mode 100644 index fc77b698..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/immortuos.yml +++ /dev/null @@ -1,214 +0,0 @@ ---- -id: immortuos -name: Immortuos -description: "Immortuos is a GERMAN Private Tracker for MOVIES / TV" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://immortuos.life/ -legacylinks: - - https://www.immortuos.life/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: PC, desc: "Apps"} - - {id: 5, cat: XXX, desc: "XXX"} - - {id: 6, cat: TV/Documentary, desc: "Doku"} - - {id: 7, cat: Console, desc: "Games"} - - {id: 8, cat: Other, desc: "Sonstiges"} - - {id: 9, cat: Movies, desc: "Kino"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_free - type: info - label: About OnlyUpload at Immortuos - default: "OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech)." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login - method: form - form: form[action$="/login"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - remember: on - selectorinputs: - _token: - selector: input[name="_token"] - attribute: value - error: - - selector: script[nonce]:contains("Error") - message: - selector: script[nonce]:contains("Error") -# test: -# path: / -# selector: a[href$="/logout"] - -search: - paths: - - path: filterTorrents - inputs: - $raw: "{{ range .Categories }}categories[]={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid search - imdb: "{{ .Query.IMDBIDShort }}" - tvdb: "{{ .Query.TVDBID }}" - tmdb: "{{ .Query.TMDBID }}" - sorting: "{{ .Config.sort }}" - direction: "{{ .Config.type }}" - qty: 100 - page: 0 - view: list - onlyup: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - # can search by genre but need range support. &genres%5B%5D=Action&genres%5B%5D=Comedy for Action and Comedy - - rows: - selector: table > tbody > tr - - fields: - category: - selector: a[href*="/categories/"] - attribute: href - filters: - - name: regexp - args: "/categories/.*?\\.(\\d+)" - title: - selector: a.view-torrent - download: - selector: a[href*="/download/"] - attribute: href - details: - selector: a.view-torrent - attribute: href - poster: - selector: div.torrent-poster img - attribute: src - filters: - - name: prepend - args: "https://images.weserv.nl/?url=" # for display on dashboard - - name: append - args: "&w=180&h=270" # for display on dashboard - - name: replace - args: ["https://images.weserv.nl/?url=https://via.placeholder.com/600x900&w=180&h=270", ""] - genre: - selector: td:nth-last-child(6) - filters: - - name: replace - args: ["\n", ""] - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: validate - args: "Action, Adventure, Animation, Biography, Comedy, Crime, Documentary, Drama, Family, Fantasy, Game-Show, History, Horror, Music, Musical, Mystery, News, Reality-TV, Romance, Science_Fiction, Sitcom, Sport, Talk-Show, Thriller, War, Western" - description: - text: "{{ .Result.genre }}" - size: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - grabs: - selector: td:nth-last-child(1) - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - tmdbid: - selector: a[href*="themoviedb.org/movie/"], a[href*="themoviedb.org/tv/"] - attribute: href - date: - selector: time - filters: - # translations for Turkish|Estonian|Danish|Italian|Polish|Norwegian|Portuguese|Czech|Russian|Romanian|Spanish|French|German|Bulgarian|Dutch|Chinese|Japanese|Swedish - - name: re_replace - args: ["(?i)(önce|tagasi|geleden|fa|temu|siden|há|atrás|nazpět|назад|acum|în urmă|hace|il y a|vor|преди|前|sedan)", " ago"] - - name: re_replace - args: ["(?i)(saniye|sekundit|sekunder|secondi|sekund|segundos|sekundami|секунд|secunde|secondes|Sekunden|секунди|seconden|秒前)", "seconds"] - - name: re_replace - args: ["(?i)(minutit|minutter|minuti|minuty|minutos|минуты|минут|Minuten|минути|minuten|minuter)", "minutes"] - - name: re_replace - args: ["(?i)(dakika|minut|minuto|minuta|minutt|минута|Minute|minuut|分钟|分)", " minute"] - - name: re_replace - args: ["(?i)(tundi|timer|ore|godziny|horas|hodiny|hoden|часа|часов|ore|heures|Stunden|timmar)", "hours"] - - name: re_replace - args: ["(?i)(saat|tund|time|ora|godzina|hora|hodina|час|oră|heure|Stunde|uur|小时|時間|timme)", " hour"] - - name: re_replace - args: ["(?i)(päeva|dage|giorni|dni|dias|dny|дня|дней|zile|días|jours|Tagen|дни|dagen|dagar)", "days"] - - name: re_replace - args: ["(?i)(gün|päev|dag|giorno|dzień|dia|den|день|zi|día|jour|Tag|ден|天|日)", " day"] - - name: re_replace - args: ["(?i)(nädalat|uger|settimane|tygodnie|uker|semanas|týdny|недели|недель|săptămâni|semaines|Wochen|седмици|weken|veckor)", "weeks"] - - name: re_replace - args: ["(?i)(hafta|nädal|uge|settimana|tydzień|uke|semana|týden|неделю|săptămână|semaine|Woche|седмица|周|週間|vecka)", " week"] - - name: re_replace - args: ["(?i) (ay)", "month"] - - name: re_replace - args: ["(?i)(kuud|måneder|mesi|miesiące|meses|měsíce|месяца|месяцев|luni|meses|mois|Monaten|месеца|maanden|månader)", "months"] - - name: re_replace - args: ["(?i)(kuu|måned|mese|miesiąc|mês|měsíc|месяц|lună|mes|Monat|месец|maand|个月|ヶ月|månad)", " month"] - - name: re_replace - args: ["(?i)(aastat|anni|lata|anos|roky|года|ani|años|ans|Jahren|години)", " years"] - - name: re_replace - args: ["(?i)(yil|aasta|år|anno|rok|ano|год|año|Jahr|година|jaar|年)", " year"] - - name: re_replace - args: ["(?i) (an)", "year"] - - name: re_replace - args: ["(?i)(För |und)", ""] # Misc removals - - name: timeago - downloadvolumefactor: - case: - i[class*="fa-id-badge text-orange"]: 0 # 24 Hour FreeLeech From BON Store - i[class*="fa-trophy text-purple"]: 0 # Special FreeLeech For Certain User Groups - i[class*="fa-star text-bold"]: 0 # Freeleech From Token - i[class*="fa-coins text-bold"]: 0 # Freeleech From Token - i[class*="fa-globe text-blue"]: 0 # Global Freeleech - i[class*="fa-star text-gold"]: 0 # Freeleech - i[class*="fa-certificate text-pink"]: 0 # Featured Torrent - "*": 1 - uploadvolumefactor: - case: - i[class*="fa-gem text-green"]: 2 # Single Torrent Double Upload - i[class*="fa-globe text-green"]: 2 # Global Double Upload - i[class*="fa-certificate text-pink"]: 2 # Featured Torrent - "*": 1 -# global MR is 1.0 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 2 days (as seconds = 5 x 24 x 60 x 60) - text: 172800 -# UNIT3D 1.9.3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/indietorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/indietorrents.yml deleted file mode 100644 index 75da7ebd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/indietorrents.yml +++ /dev/null @@ -1,141 +0,0 @@ ---- -id: indietorrents -name: Indietorrents -description: "Indietorrents is a Private Music Torrent Tracker for independent MUSIC" -language: en-GB -encoding: UTF-8 -type: private -links: - - https://www.indietorrents.com/ - -caps: - categorymappings: - # there is a cat 2 Video but it is not supported by this indexer. - - {id: 1, cat: Audio, desc: "Music"} - - modes: - search: [q] - music-search: [q, album, artist] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_cats - type: info - label: "About categories" - default: "This indexer only supports searching the Audio category. Video searching is not supported." - - name: info_results - type: info - label: "Search results" - default: "This indexer does not support Torrent Groups.
On your account Settings change Torrent grouping to DO NOT Group torrents by default." - -login: - path: login.php?lang= - method: form - form: form#loginform - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: form#loginform:contains("incorrect") - test: - path: index.php - selector: a[href^="logout.php?auth="] - -search: - paths: - # https://www.indietorrents.com/torrents.php?searchstr=lily+konigsberg+we+need+to+talk+now+2021&action=basic&taglist=&tags_type=1&order_by=time&order_way=desc - - path: torrents.php - inputs: - searchstr: "{{ if or (.Query.Artist) (.Query.Album) }}{{ or (.Query.Artist) (.Query.Album) }}{{ else }}{{ .Keywords }}{{ end }}" - action: basic - taglist: "" - tags_type: 1 - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - # restrict to audio as video generates different html on a mixed results page. - filter_cat[1]: 1 - - rows: - selector: table#torrent_table > tbody > tr[class^="group"] - after: 2 - - fields: - category: - text: 1 - details: - selector: a[href^="torrents.php?id="]:last-of-type - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - _tags: - selector: div.tags - description: - selector: td.edition_info - filters: - - name: append - args: "
{{ .Result._tags }}" - files: - selector: td:nth-last-child(7) - date: - selector: td:nth-last-child(6) > span.time - attribute: title - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM d yyyy, HH:mm zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - "strong:contains(\"Freeleech!\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - title_details: - # artist and album - selector: td:nth-child(3) - remove: span, div.tags - title: - # torrent info - selector: a[href*="torrentid="] - remove: strong - filters: - - name: prepend - args: "{{ .Result.title_details }} " - minimumratio: - text: 0.60 -# Project Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infinity-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infinity-api.yml deleted file mode 100644 index a2ade03a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infinity-api.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: infinity-api -name: INFINITY (API) -description: "INFINITY is a Private PAY2DL Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://infinitylibrary.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your INFINITY account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "The system bot will prune inactive users after 90 days of no activity. If you wish to keep your account be sure to login." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.6 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.6 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 8.3.6 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infinityhd-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infinityhd-api.yml deleted file mode 100644 index 1e5ce66e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infinityhd-api.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: infinityhd-api -name: InfinityHD (API) -description: "InfinityHD is a Private Torrent Tracker for MOVIES / TV / ANIME" -language: en-US -type: private -encoding: UTF-8 -links: - - https://infinityhd.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: TV/Anime, desc: "Anime"} - - {id: 4, cat: Movies/Other, desc: "Anime Movie"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your InfinityHD account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your account will be automatically disabled after 90 days of inactivity. After 120 days your account will be pruned and is permanently deleted." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 5 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infire.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infire.yml deleted file mode 100644 index 2be74e43..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/infire.yml +++ /dev/null @@ -1,221 +0,0 @@ ---- -id: infire -name: Infire -description: "Infire is a SLOVENIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: sl-SI -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://infire.si/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - # Filmi - - {id: 14, cat: Movies/SD, desc: "XviD", default: true} - - {id: 15, cat: Movies/HD, desc: "HD 720p", default: true} - - {id: 16, cat: Movies/HD, desc: "HD 1080p", default: true} - - {id: 20, cat: Movies/WEB-DL, desc: "WEB/Rip", default: true} - - {id: 21, cat: Movies/WEB-DL, desc: "WEB 720p", default: true} - - {id: 49, cat: Movies/UHD, desc: "HD 2160p", default: true} - - {id: 50, cat: Movies/HD, desc: "BluRay 1080p", default: true} - - {id: 51, cat: Movies/HD, desc: "720p H265", default: true} - - {id: 52, cat: Movies/HD, desc: "1080p H265", default: true} - - {id: 55, cat: Movies/WEB-DL, desc: "WEB 1080p", default: true} - - {id: 71, cat: Movies/WEB-DL, desc: "WEB 2160p", default: true} - - {id: 72, cat: Movies/HD, desc: "BluRay 720p", default: true} - - {id: 73, cat: Movies/HD, desc: "BluRay Remux", default: true} - - {id: 74, cat: Movies/UHD, desc: "UHD Remux", default: true} - - {id: 77, cat: Movies/HD, desc: "DrSi", default: true} - - {id: 97, cat: Movies/UHD, desc: "BluRay 2160p", default: true} - - {id: 99, cat: Movies/HD, desc: "BRRip 720p", default: true} - - {id: 100, cat: Movies/HD, desc: "BRRip 1080p", default: true} - - {id: 101, cat: Movies/HD, desc: "BRRip", default: true} - # TV - - {id: 23, cat: TV, desc: "TV", default: true} - - {id: 24, cat: TV/HD, desc: "HDTV 720p", default: true} - - {id: 75, cat: TV/HD, desc: "HDTV 1080p", default: true} - - {id: 76, cat: TV/WEB-DL, desc: "TV WEB/Rip", default: true} - - {id: 78, cat: TV/WEB-DL, desc: "TV WEB 720p", default: true} - - {id: 79, cat: TV/WEB-DL, desc: "TV WEB 1080p", default: true} - - {id: 80, cat: TV/WEB-DL, desc: "TV WEB 2160p", default: true} - - {id: 81, cat: TV/HD, desc: "TV BluRay 720p", default: true} - - {id: 82, cat: TV/HD, desc: "TV BluRay 1080p", default: true} - - {id: 98, cat: TV/UHD, desc: "TV BluRay 2160p", default: true} - # Risanke - - {id: 26, cat: Movies/Other, desc: "Animirano", default: true} - - {id: 56, cat: TV/Anime, desc: "Anime", default: true} - - {id: 93, cat: Movies/Other, desc: "SLOSiNH", default: true} - # Igre - - {id: 29, cat: PC/Games, desc: "PC ISO", default: true} - - {id: 30, cat: Console/Wii, desc: "Nintendo", default: true} - - {id: 31, cat: Console/PS3, desc: "Playstation", default: true} - - {id: 34, cat: Console/XBox One, desc: "Xbox", default: true} - - {id: 83, cat: PC/Games, desc: "Repack", default: true} - # Glasba - - {id: 42, cat: Audio/Video, desc: "Music DVD", default: true} - - {id: 43, cat: Audio/Video, desc: "Videospoti", default: true} - - {id: 53, cat: Audio/MP3, desc: "Glasba MP3", default: true} - - {id: 54, cat: Audio/Lossless, desc: "Glasba FLAC", default: true} - - {id: 62, cat: Audio/Video, desc: "Koncerti", default: true} - # Sport - - {id: 37, cat: TV/Sport, desc: "Sport", default: true} - # XXX - - {id: 60, cat: XXX/Other, desc: "XXX Revije", default: false} - - {id: 61, cat: XXX/Pack, desc: "XXX Packs", default: false} - - {id: 66, cat: XXX/SD, desc: "XXX 480p", default: false} - - {id: 67, cat: XXX/x264, desc: "XXX 720p", default: false} - - {id: 68, cat: XXX/x264, desc: "XXX 1080p", default: false} - - {id: 69, cat: XXX/UHD, desc: "XXX 2160p", default: false} - - {id: 84, cat: XXX/ImageSet, desc: "XXX Slike", default: false} - # Disc - - {id: 85, cat: Movies/DVD, desc: "DVD 5", default: true} - - {id: 86, cat: Movies/DVD, desc: "DVD 9", default: true} - - {id: 87, cat: Movies/BluRay, desc: "BD 25", default: true} - - {id: 88, cat: Movies/BluRay, desc: "BD 50", default: true} - - {id: 89, cat: Movies/BluRay, desc: "UHD 50", default: true} - - {id: 90, cat: Movies/BluRay, desc: "UHD 66", default: true} - - {id: 91, cat: Movies/BluRay, desc: "UHD 100", default: true} - # Razno - - {id: 38, cat: PC/0day, desc: "Programi", default: true} - - {id: 39, cat: Books/EBook, desc: "eKnjige", default: true} - - {id: 40, cat: Other, desc: "Slike", default: true} - - {id: 41, cat: Audio/Audiobook, desc: "AudioBook", default: true} - - {id: 44, cat: PC/Mobile-Android, desc: "Android", default: true} - - {id: 45, cat: PC/Mobile-iOS, desc: "iOS", default: true} - - {id: 46, cat: PC/Mac, desc: "Mac", default: true} - - {id: 47, cat: PC/ISO, desc: "Linux", default: true} - - {id: 102, cat: Other, desc: "3D STL", default: true} - # Dokumentarni - - {id: 95, cat: TV/Documentary, desc: "Dokumentarni Film", default: true} - - {id: 96, cat: TV/Documentary, desc: "Dokumentarna Serija", default: true} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - seeds: seeders - size: size - filename: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_flaresolverr - type: info_flaresolverr - - name: info_activity - type: info - label: Account Inactivity - default: "The period of inactivity after which we delete your account is 3 months. VIP users are not deleted." - -login: - path: login.php - method: form - form: form[action^="login.php"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - login: "Prijavi se" - error: - - selector: font[color="#FF0000"] - test: - path: index.php - selector: a[href="logout.php"] - -download: - headers: - Referer: ["{{ .Config.sitelink }}torrents.php"] - -search: - paths: - - path: torrents.php - categories: [14, 15, 16, 20, 21, 49, 50, 51, 52, 55, 71, 72, 73, 74, 77, 97, 99, 100, 23, 24, 75, 76, 78, 79, 80, 81, 82, 98, 26, 56, 93, 29, 30, 31, 34, 83, 42, 43, 53, 54, 62, 37, 85, 86, 87, 88, 89, 90, 91, 38, 39, 40, 41, 44, 45, 46, 47, 102, 95, 96] - - path: xxx.php - categories: [60, 61, 66, 67, 68, 69, 84] - inputs: - # does not support multi categories, defaulting to all. - search: "{{ .Keywords }}" - # 0 all, 1 active, 2 dead - active: 0 - # 0 all, 1 classic, 2 silver, 3 gold, 4 gold & silver - gold: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.mg-b-0 > tbody > tr:has(a[href^="download.php?id="]) - - fields: - categorydesc: - selector: span:has(i.fa-compact-disc) - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img - attribute: src - size: - selector: td:nth-last-child(4) - date: - selector: td:nth-last-child(5) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd/MM/yyyyHH:mm:ss" - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - grabs: - selector: td:nth-last-child(1) - genre: - selector: td:nth-child(2) > span:nth-child(5) - filters: - - name: re_replace - args: ["(?i)(Znanstvena fantastika)", "Znanstvena_fantastika"] - - name: validate - args: "Akcija, Animirani, Animacija, Avantura, Biografski, Dokumentarni, Drама, Družinski, Fantastika, Fantazija, Glasbeni, Grozljivka, Комеdija, Kratek, Kriminalka, Кriminalni, Misterij, Mladinski, Potovanje, Pustolovski, Romantika, Sport, Sci-Fi, Тriler, Vojni, Western, Zgodovinski, Znanstvena_fantastika" - description: - text: "{{ .Result.genre }}" - downloadvolumefactor: - case: - "span:contains(\"100% Freeleech\")": 0 - "span:contains(\"50% Freeleech\")": 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# Engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/insanetracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/insanetracker.yml deleted file mode 100644 index c96b2383..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/insanetracker.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: insanetracker -name: Insane Tracker -description: "Insane Tracker is a HUNGARIAN Private Torrent Tracker for 0DAY / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://newinsane.info/ - -caps: - categorymappings: - - {id: 8, cat: TV/SD, desc: "Sorozat/Hun"} - - {id: 40, cat: TV/HD, desc: "Sorozat/Hun/HD"} - - {id: 47, cat: TV/UHD, desc: "Sorozat/Hun/UHD"} - - {id: 7, cat: TV/SD, desc: "Sorozat/Eng"} - - {id: 39, cat: TV/HD, desc: "Sorozat/Eng/HD"} - - {id: 46, cat: TV/UHD, desc: "Sorozat/Eng/UHD"} - - {id: 41, cat: Movies/SD, desc: "Film/Hun/SD"} - - {id: 27, cat: Movies/HD, desc: "Film/Hun/HD"} - - {id: 44, cat: Movies/UHD, desc: "Film/Hun/UHD"} - - {id: 15, cat: Movies/DVD, desc: "Film/Hun/DVD-R"} - - {id: 42, cat: Movies/SD, desc: "Film/Eng/SD"} - - {id: 25, cat: Movies/HD, desc: "Film/Eng/HD"} - - {id: 45, cat: Movies/UHD, desc: "Film/Eng/UHD"} - - {id: 14, cat: Movies/DVD, desc: "Film/Eng/DVD-R"} - - {id: 2, cat: Books, desc: "eBook/Hun"} - - {id: 1, cat: Books, desc: "eBook/Eng"} - - {id: 38, cat: Audio/Audiobook, desc: "Hangoskönyv"} - - {id: 19, cat: Audio/MP3, desc: "Zene/Hun"} - - {id: 37, cat: Audio/Lossless, desc: "Lossless/Hun"} - - {id: 18, cat: Audio/MP3, desc: "Zene/Eng"} - - {id: 26, cat: Audio/Lossless, desc: "Lossless/Eng"} - - {id: 4, cat: PC/ISO, desc: "Program/ISO"} - - {id: 3, cat: PC/0day, desc: "Program/Egyéb"} - - {id: 11, cat: PC/Games, desc: "Játék/Rip"} - - {id: 9, cat: PC/Games, desc: "Játék/ISO"} - - {id: 43, cat: Console, desc: "Játék/Konzol"} - - {id: 13, cat: PC/Mobile-Other, desc: "Mobil"} - - {id: 21, cat: XXX, desc: "XXX"} - - {id: 28, cat: XXX/ImageSet, desc: "XXX Képek"} - - {id: 29, cat: Other, desc: "Képek"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "After half a year of inactivity you will first be placed in a parked state which ensures that you can enter for another year and a half. If you don't log in by then, the system will permanently delete it." - -login: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - notsecure: "" - error: - - selector: div.login_error_content_text - test: - path: index.php - selector: a[href$="logout.php"] - -search: - paths: - # https://newinsane.info/browse.php?search=&nohnr=1&doubleup=&incldead=1&ggen=0&sty=0&all=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 both, 2 dead - incldead: 1 - nohnr: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table.torrenttable > tbody > tr:has(td.torrentmain) - - fields: - title: - selector: td.torrentmain > a.torrentname - attribute: title - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - details: - selector: td.torrentmain > a.torrentname - attribute: href - download: - selector: a[href*="/download.php/"] - attribute: href - imdbid: - selector: a[title="IMDb link"] - attribute: href - poster: - selector: img[alt="offer"], a.cover - attribute: src - files: - selector: div.files > a.button.small - size: - selector: td.center.size - seeders: - selector: td.center.data > a:first-child - leechers: - selector: td.center.data > a:nth-child(2) - date: - selector: td.center.date - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm zzz" - downloadvolumefactor: - case: - a[href^="?nohnr=1"]: 0 - "*": 1 - uploadvolumefactor: - case: - a[href^="?doubleup=four"]: 4 - a[href^="?doubleup=three"]: 3 - a[href^="?doubleup=two"]: 2 - "*": 1 - description_text: - selector: div.contenttext - optional: true - remove: div.contenttitle - description: - case: - span.accepted: "Verified: {{ .Result.description_text }}" - span.notchecked: "Unverified: {{ .Result.description_text }}" - grabs: - selector: td.center.data - remove: div.uploader, div.moderation, a.leftborder - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# custom v2.0b diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/internetarchive.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/internetarchive.yml deleted file mode 100644 index 169000f5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/internetarchive.yml +++ /dev/null @@ -1,110 +0,0 @@ ---- -id: internetarchive -name: Internet Archive -description: "Internet Archive is a non-profit digital library offering free universal access to books, movies & music, as well as 406 billion archived web pages" -language: en-US -type: public -encoding: UTF-8 -links: - - https://archive.org/ - -caps: - categorymappings: - - {id: audio, cat: Audio, desc: audio} - - {id: etree, cat: Audio, desc: etree} - - {id: movies, cat: Movies, desc: movies} - - {id: movies, cat: TV, desc: movies} - - {id: image, cat: Other/Misc, desc: image} - - {id: texts, cat: Books, desc: texts} - - {id: software, cat: PC, desc: software} - - {id: web, cat: Other, desc: web} - - {id: collection, cat: Other, desc: collection} - - {id: account, cat: Other, desc: account} - - {id: data, cat: Other, desc: data} - - {id: other, cat: Other, desc: other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: titleOnly - type: checkbox - label: Search only in title - default: true - - name: noMagnet - type: checkbox - label: Download using .torrent only. No Magnets. - default: false - - name: sort - type: select - label: Sort requested from site - default: publicdate - options: - publicdate: created - downloads: downloads - item_size: size - - name: type - type: select - label: Order requested from site - default: "-" - options: - "-": desc - "_": asc - -search: - paths: - - path: advancedsearch.php - response: - type: json - - inputs: - q: "{{ if and .Config.titleOnly .Keywords }}title:({{ else }}{{ end }}{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ end }}{{ if and .Config.titleOnly .Keywords }}){{ else }}{{ end }}{{ if .Keywords }} AND {{ else }}{{ end }}format:(\"Archive BitTorrent\"){{ if .Categories }} AND mediatype:({{ join .Categories \" OR \" }}){{ else }}{{ end }}" - fl[]: "identifier,title,mediatype,item_size,downloads,btih,publicdate" - sort: "{{ if .Keywords }}{{ re_replace .Config.type \"_\" \"\" }}{{ .Config.sort }}{{ else }}-publicdate{{ end }}" - rows: 100 - output: json - - rows: - selector: response.docs - count: - selector: response.numFound - - fields: - _id: - selector: identifier - category: - selector: mediatype - title: - selector: title - optional: true - default: "Missing title for {{ .Result._id }}" - details: - text: "details/{{ .Result._id }}" - download: - text: "download/{{ .Result._id }}/{{ .Result._id }}_archive.torrent" - _btih: - selector: btih - optional: true - infohash: - text: "{{ if .Config.noMagnet }}{{ else }}{{ .Result._btih }}{{ end }}" - date: - # 2021-10-25T16:44:43Z - selector: publicdate - size: - selector: item_size - grabs: - selector: downloads - optional: true - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json Elasticsearch diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/itatorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/itatorrents.yml deleted file mode 100644 index 60e43e85..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/itatorrents.yml +++ /dev/null @@ -1,212 +0,0 @@ ---- -id: itatorrents -name: ItaTorrents -description: "ItaTorrents is an ITALIAN Private tracker for MOVIES / TV / GENERAL" -language: it-IT -type: private -encoding: UTF-8 -links: - - https://itatorrents.xyz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Film"} - - {id: 2, cat: TV, desc: "Serie TV"} - - {id: 3, cat: Audio, desc: "Musica"} - - {id: 4, cat: Console, desc: "Giochi"} - - {id: 5, cat: PC, desc: "Applicazioni"} - - {id: 6, cat: Books/Mags, desc: "Edicola"} - - {id: 7, cat: Books/EBook, desc: "E-Book"} - - {id: 8, cat: Audio/Audiobook, desc: "AudioLibri"} - - {id: 12, cat: Other, desc: "Corsi"} - - {id: 13, cat: Books/Comics, desc: "Fumetti"} - - {id: 14, cat: Audio/Video, desc: "Concerti"} - - {id: 15, cat: Audio, desc: "Discografie"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your ItaTorrents account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To not get disabled, you are required to login to the site at least once every 90 days or be actively seeding at least 1 torrent." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - name: re_replace # strip season and/or ep when single keyword (needed for mixed usage of S01 and Stagione 1) - args: ["^([SE]\\d{1,4}){1,2}$", "{{ if or (.Query.Season) (.Query.Ep) }}{{ else }}$1{{ end }}"] - - name: re_replace # S01 to Stagione 1 - args: ["(?i)\\bS0*(\\d+)\\b", "Stagione $1"] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - filters: - - name: re_replace - args: ["\\.", " "] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(televisione film)", "televisione_film"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jme-reunit3d-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jme-reunit3d-api.yml deleted file mode 100644 index a7a17da4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jme-reunit3d-api.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: jme-reunit3d-api -name: JME-REUNIT3D (API) -description: "JME-REUNIT3D is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://jme-reunit3d.de/ -legacylinks: - - https://jme-reunit3d.eu/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 6, cat: XXX, desc: "XXX"} - - {id: 7, cat: TV/Sport, desc: "Sport"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 5, cat: PC, desc: "Application"} - - {id: 4, cat: Console, desc: "Game"} - - {id: 8, cat: Books, desc: "E-Books"} - - {id: 9, cat: Audio/Audiobook, desc: "Audio-Books"} - - {id: 12, cat: Audio/Video, desc: "Music-Video"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your JME-REUNIT3D account My Security page and clicking on the API Token tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If a user has not actively logged in for 30 days, the profile is automatically deactivated and receives the user class 'Disabled'.
After deactivation, the profile can be reactivated within 60 days – after which it will be permanently deleted." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - categorydesc: - selector: category - title: - selector: name - details: - selector: details_link - download: - selector: download_link - poster: - selector: poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - files: - selector: num_file - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - downloadvolumefactor: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - uploadvolumefactor: - # api returns 0=false, 1=true - selector: double_upload - case: - 0: 1 # normal - 1: 2 # double - minimumseedtime: - # 4 days (as seconds = 4 x 24 x 60 x 60) - text: 345600 -# json UNIT3D 6.0.4 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/joyhd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/joyhd.yml deleted file mode 100644 index b9e8578b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/joyhd.yml +++ /dev/null @@ -1,174 +0,0 @@ ---- -id: joyhd -name: JoyHD -description: "JoyHD (JHD) is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.joyhd.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影Movies"} - - {id: 402, cat: TV, desc: "剧集TV Series"} - - {id: 403, cat: TV/Other, desc: "综艺TV Shows"} - - {id: 405, cat: TV/Anime, desc: "动漫Animations"} - - {id: 414, cat: Audio, desc: "音乐Music"} - - {id: 407, cat: TV/Sport, desc: "体育Sports"} - - {id: 404, cat: TV/Documentary, desc: "纪录片Documentaries"} - - {id: 406, cat: Audio/Video, desc: "音乐视频Music Videos"} - - {id: 408, cat: PC, desc: "软件Appz"} - - {id: 410, cat: PC/Games, desc: "游戏Game"} - - {id: 411, cat: Other, desc: "学习Study"} - - {id: 409, cat: Other, desc: "其他Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: takelogin.php - method: post - inputs: - loginmethod: username - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - dutime: month - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (4 does not appear to work). - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # currently not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # currently not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.81 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v0.3 2013-10-01 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jpopsuki.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jpopsuki.yml deleted file mode 100644 index 642fb29c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jpopsuki.yml +++ /dev/null @@ -1,159 +0,0 @@ ---- -id: jpopsuki -name: JPopsuki -description: "JPopSuki is a Private Torrent Tracker for ASIAN MUSIC" -language: en-US -type: private -encoding: UTF-8 -requestDelay: 3 -links: - - https://jpopsuki.eu/ - -caps: - categorymappings: - - {id: 1, cat: Audio, desc: "Album"} - - {id: 2, cat: Audio, desc: "Single"} - - {id: 3, cat: Movies, desc: "PV"} - - {id: 4, cat: Movies/DVD, desc: "DVD"} - - {id: 5, cat: TV, desc: "TV-Music"} - - {id: 6, cat: TV, desc: "TV-Variety"} - - {id: 7, cat: TV, desc: "TV-Drama"} - - {id: 8, cat: Other, desc: "Fansubs"} - - {id: 9, cat: Other, desc: "Pictures"} - - {id: 10, cat: Other/Misc, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, genre] - movie-search: [q, genre] - music-search: [q, genre] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: s3 - options: - s3: created - s6: seeders - s4: size - s1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - login: "Log In!" - error: - - selector: form#loginform > span.warning, font[color="red"] - test: - path: index.php - selector: a[href^="logout.php?auth="], a[href^="torrents.php?action=download&id="] - -search: - # https://jpopsuki.eu/ajax.php?section=torrents&searchstr=snow man&action=basic&searchtags=&tags_type=0&order_by=s3&order_way=desc&filter_cat[5]=1 - paths: - - path: ajax.php - keywordsfilters: - - name: re_replace # replace special characters with " " (space) - args: ["[\\[!\"#$%&'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]", " "] - inputs: - section: torrents - disablegrouping: 1 - searchstr: "{{ .Keywords }}" - action: basic - searchtags: "{{ .Query.Genre }}" - # 0 Any, 1 All - tags_type: 1 - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - $raw: "{{ range .Categories }}&filter_cat[{{.}}]=1{{end}}" - error: - - selector: :root:contains("Database error.") - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent"]:has(a[href^="torrents.php?action=download&id="]) - - fields: - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "%5B(\\d+?)%5D" - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - genre: - selector: div.tags - filters: - - name: replace - args: [".", "_"] - description: - text: "{{ .Result.genre }}" - artist: - selector: td:nth-last-child(7) a[href*="artist.php"] - optional: true - title: - selector: td:nth-last-child(7) - remove: span, div.tags, a[title="View Comments"] - filters: - - name: replace - args: [" ()", ""] - - name: replace - args: ["\t", ""] - - name: replace - args: [" / Freeleech!", ""] - details: - selector: a[href^="torrents.php?id="] - attribute: href - poster: - selector: img[src^="static/images/torrents/"] - attribute: src - files: - selector: td:nth-last-child(6) - date: - selector: td:nth-last-child(5) - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-last-child(4) - grabs: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - downloadvolumefactor: - case: - "strong:contains(\"Freeleech!\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.6 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jptv4us-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jptv4us-api.yml deleted file mode 100644 index f6847041..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/jptv4us-api.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: jptv4us-api -name: JPTV4us (API) -description: "JPTV4us is a JAPANESE Private Torrent Tracker for MOVIES / TV / MUSIC / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://jptv4.us/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime Movie"} - - {id: 2, cat: TV/Anime, desc: "Anime Series"} - - {id: 3, cat: TV/Documentary, desc: "Documentary"} - - {id: 4, cat: TV, desc: "Drama"} - - {id: 5, cat: TV/Other, desc: "Informational"} - - {id: 6, cat: Movies, desc: "Movie"} - - {id: 7, cat: Audio/Video, desc: "Music"} - - {id: 8, cat: TV/Other, desc: "News"} - - {id: 9, cat: TV/Other, desc: "Variety"} - - {id: 10, cat: Other, desc: "Other"} - - {id: 11, cat: Audio, desc: "Audio Only"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your JPTV4us account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.1.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kamept.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kamept.yml deleted file mode 100644 index 95322623..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kamept.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: kamept -name: KamePT -description: "KamePT is a CHINESE Private Torrent Tracker for 3X" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://kamept.com/ - -caps: - categorymappings: - - {id: 410, cat: XXX, desc: "同人AV"} - - {id: 413, cat: XXX, desc: "男娘"} - - {id: 414, cat: XXX, desc: "同人VR"} - - {id: 417, cat: XXX, desc: "Cosplay套图"} - - {id: 419, cat: XXX, desc: "里番"} - - {id: 411, cat: XXX, desc: "2D动画"} - - {id: 423, cat: XXX, desc: "3D动画"} - - {id: 424, cat: XXX/ImageSet, desc: "单行本"} - - {id: 435, cat: XXX/ImageSet, desc: "同人志"} - - {id: 433, cat: XXX/ImageSet, desc: "画师CG"} - - {id: 415, cat: XXX/Other, desc: "游戏"} - - {id: 418, cat: XXX/Other, desc: "中文游戏"} - - {id: 434, cat: XXX/ImageSet, desc: "游戏CG"} - - {id: 420, cat: XXX/Other, desc: "外语音声"} - - {id: 421, cat: XXX/Other, desc: "中文音声"} - - {id: 422, cat: XXX/Other, desc: "视频音声"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="usercp.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.9 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP KEMURIKUSA 2019-01-09 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/karagarga.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/karagarga.yml deleted file mode 100644 index 8d4a0be5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/karagarga.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: karagarga -name: Karagarga -description: "Karagarga is a Private Torrent Tracker that tracks non-hollywood, rare and obscure MOVIES, MUSIC and LITERATURE." -language: en-US -type: private -encoding: iso-8859-1 -links: - - https://karagarga.in/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: Audio, desc: "Music"} - - {id: 3, cat: Books, desc: "Literature"} - - modes: - search: [q] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - captcha: - type: image - selector: img#captcha_img - input: captcha - error: - - selector: table:contains("Login failed!") - - selector: table:contains("Login unsuccessful") - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - # https://karagarga.in/browse.php?search_type=title&search=&cat=3&genre=&subgenre=&country=0&hdrip=&sort=&d=&incldead= - - path: browse.php - inputs: - # torrent, title, director, uploader, year, imdb - search_type: "{{ if .Query.IMDBID }}imdb{{ else }}title{{ end }}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # site does not support multiple cats - cat: 0 - genre: "" - subgenre: "" - # 0 any, 2 USA, 12 UK, etc - country: 0 - hdrip: "" - # "" yes, 1 no - incldead: "" - source: "" - fl: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - # sort keywordless search to get the latest, but dont sort keyword search to allow andmatch to return more results. - sort: "{{ if .Keywords }}{{ else }}added{{ end }}" - d: "{{ if .Keywords }}{{ else }}DESC{{ end }}" - # site supports imdbid searches and returns imdb links in results. - - rows: - selector: table#browse > tbody > tr:has(a[href^="browse.php?genre="]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?genre="] img - case: - img[title^="Movie"]: 1 - img[title^="Music"]: 2 - img[title^="Literature"]: 3 - year: - selector: a[href$="search_type=year"] - optional: true - _flag: - selector: a[href^="browse.php?country="] img - attribute: title - optional: true - _filename: - selector: a[href^="/down.php/"] - attribute: href - filters: - - name: regexp - args: "(?:/down\\.php/\\d+/)(.+?)\\.torrent" - - name: re_replace - args: ["%20", "."] - title: - selector: a[href^="details.php?id="] - filters: - - name: append - args: " {{ .Result.year }} {{ .Result._flag }} [{{ .Result._filename }}]" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="/down.php/"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(9) - filters: - - name: re_replace - args: ["([a-zA-Z]+)\\s+(\\d{1,2})\\s+'(\\d{2})", "$2 $1 $3"] - - name: dateparse - args: "d MMM yy" - files: - selector: td:nth-child(10) - size: - selector: td:nth-child(11) - grabs: - selector: td:nth-child(12) - seeders: - selector: td:nth-child(13) - leechers: - selector: td:nth-child(14) - _subs: - selector: span:contains("Subs:") - optional: true - genre: - selector: td:nth-child(5) - filters: - - name: replace - args: ["\n", " "] - _mom: - selector: img[title^="CURRENT"] - attribute: title - optional: true - description: - text: "{{ if .Result._subs }}Subs: {{ .Result._subs }}
{{ else }}{{ end }}{{ if .Result.genre }}Genre: {{ .Result.genre }}
{{ else }}{{ end }}{{ if .Result._mom }}Current MoM{{ else }}{{ end }}" - downloadvolumefactor: - case: - "span:contains(\"Freeleech\")": 0 - "span:contains(\"Featured\")": 0 - "*": 1 - uploadvolumefactor: - case: -# ":has(img[title^=\"HD\"]):has(img[title^=\"CURRENT\"])": 1.8 - "img[title^=\"CURRENT\"]": 1.6 -# "img[title^=\"HD\"]": 1.3 - "*": 1.1 - minimumratio: - text: 0.25 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/keepfriends.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/keepfriends.yml deleted file mode 100644 index 4be3a4c1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/keepfriends.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: keepfriends -name: Keep Friends -description: "Keep Friends (FRDS-PT) is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.keepfrds.com/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 301, cat: Movies, desc: "Movie Packs/电影(合集)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 304, cat: TV/Documentary, desc: "Documentary Packs/纪录片(合集)"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 305, cat: TV/Anime, desc: "Animation Packs/动漫(合集)"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 302, cat: TV, desc: "TV Serie Packs/剧集(合集)"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 303, cat: TV, desc: "TV Show Packs/综艺(合集)"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 307, cat: TV/Sport, desc: "Sport Packs/体育(合集)"} - - {id: 406, cat: Audio/Video, desc: "MusicVideo/音乐录影带"} - - {id: 306, cat: Audio/Video, desc: "MusicVideo Packs/音乐录影带(合集)"} - - {id: 408, cat: Audio, desc: "Music/音乐"} - - {id: 308, cat: Audio, desc: "Music Packs/音乐(合集)"} - - {id: 409, cat: Other, desc: "Misc/其他"} - - {id: 309, cat: Other, desc: "Misc Packs/其他(合集)"} - - {id: 300, cat: Other, desc: "Recycle Bin/回收站"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: english_title - type: checkbox - label: "Use English titles instead of Chinese ones (when available)." - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 10: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[onclick="document.getElementById('logout').submit();"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl, 5 doubankeywords (not working) - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # supports imdbid searches but does not display imdb links in results. - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title_raw: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - # note: final title processing is at the bottom after descriptions is fetched. - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - selector: a[href*="movie.douban.com/subject/"] - attribute: href - _staff_edit: - selector: a[href^="edit.php?"] - attribute: href - optional: true - date_elapsed: - # time type: time elapsed (default) - selector: "{{ if .Result._staff_edit }}td:nth-child(5) > span[title]{{ else }}td:nth-child(4) > span[title]{{ end }}" - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: "{{ if .Result._staff_edit }}td:nth-child(5):not(:has(span)){{ else }}td:nth-child(4):not(:has(span)){{ end }}" - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: "{{ if .Result._staff_edit }}td:nth-child(6){{ else }}td:nth-child(5){{ end }}" - seeders: - selector: "{{ if .Result._staff_edit }}td:nth-child(7){{ else }}td:nth-child(6){{ end }}" - leechers: - selector: "{{ if .Result._staff_edit }}td:nth-child(8){{ else }}td:nth-child(7){{ end }}" - grabs: - selector: "{{ if .Result._staff_edit }}td:nth-child(9){{ else }}td:nth-child(8){{ end }}" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: "{{ if .Result._staff_edit }}td:nth-child(3){{ else }}td:nth-child(2){{ end }}" - remove: a, b, font, img, span - title_english: - selector: table.torrentname > tbody > tr > td.embedded - title: - text: "{{ if and .Config.english_title .Result.title_english }}{{ .Result.title_english }}{{ else }}{{ .Result.title_raw }}{{ end }}" -# NexusPHP Standard v1.5 Beta 4 (custom title search) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kelu.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kelu.yml deleted file mode 100644 index f3125dd8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kelu.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -id: kelu -name: Kelu -description: "Kelu is a CHINESE Private Torrent Tracker for GAY 3X" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://our.kelu.one/ - -caps: - categorymappings: - - {id: 408, cat: XXX/Other, desc: "其他"} - - {id: 409, cat: XXX/Other, desc: "动漫/二次元"} - - {id: 403, cat: XXX, desc: "SM"} - - {id: 402, cat: XXX, desc: "日韩"} - - {id: 405, cat: XXX, desc: "欧美"} - - {id: 404, cat: XXX, desc: "国产"} - - {id: 401, cat: XXX/ImageSet, desc: "写真"} - - {id: 410, cat: XXX, desc: "影视"} - - {id: 411, cat: PC/Games, desc: "游戏"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the Kelu Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts (i.e. not logged in for a long time) are automatically deleted." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - case: - img.hitandrun: 86400 - "*": 0 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kickasstorrents-to.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kickasstorrents-to.yml deleted file mode 100644 index 8bc9c85f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kickasstorrents-to.yml +++ /dev/null @@ -1,169 +0,0 @@ ---- -id: kickasstorrents-to -name: kickasstorrents.to -description: "kickasstorrents.to is a Public KickAssTorrent clone for MOVIES / TV / GENERAL" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://kickass.torrentbay.st/ - - https://kickass.torrentsbay.org/ - - https://kickasstorrents.unblockninja.com/ - - https://kickasstorrents.ninjaproxy1.com/ - - https://kickasstorrents.proxyninja.org/ - - https://kickasstorrents.proxyninja.net/ -legacylinks: - - https://kat.root.yt/ - - https://kickasstorrents.abcproxy.org/ - - https://kickasstorrents.to/ - - https://kickasstorrent.cr/ # blocks Jackett UA, 'new' page broken - - https://katcr.to/ # blocks Jackett UA - - https://www.kickasstorrents.do/ # blocks Jackett UA - -caps: - categorymappings: - # category naming is inconsistent with root cat being left out on some results, hence the duplicate entries with/without root - - {id: 3DMovies, cat: Movies/3D, desc: "Movies 3D Movies"} - - {id: Adult, cat: XXX/WEB-DL, desc: "Adult Video"} - - {id: AdultGames, cat: XXX/Other, desc: "Adult Games"} - - {id: AdultHentai, cat: XXX/Other, desc: "Adult Hentai"} - - {id: AdultMagazines, cat: XXX/Other, desc: "Adult Magazines"} - - {id: AdultPictures, cat: XXX/ImageSet, desc: "Adult Pictures"} - - {id: AdultVideo, cat: XXX/WEB-DL, desc: "Adult Video"} - - {id: Android, cat: PC/Mobile-Android, desc: "Apps Android"} - - {id: Anime, cat: TV/Anime, desc: Anime} - - {id: AnimeAudioLossless, cat: Audio/Lossless, desc: "Anime Audio Lossless"} - - {id: "AnimeAudio[Lossless]", cat: Audio/Lossless, desc: "Anime Audio Lossless"} - - {id: "AnimeAudio[Lossy]", cat: Audio/MP3, desc: "Anime Audio Lossy"} - - {id: AnimeEnglish-translated, cat: TV/Anime, desc: "Anime English-translated"} - - {id: "AnimeLiveAction[English-translated]", cat: TV/Anime, desc: "Anime Live Action English-translated"} - - {id: "AnimeLiveAction[Non-English]", cat: TV/Anime, desc: "Anime Live Action Non-English"} - - {id: "AnimeLiveAction[Raw]", cat: TV/Anime, desc: "Anime Live Action Raw"} - - {id: "AnimeManga[English-translated]", cat: TV/Anime, desc: "Anime Manga English-translated"} - - {id: "AnimeManga[Raw]", cat: TV/Anime, desc: "Anime Manga Raw"} - - {id: AnimeMusicvideo, cat: Audio/Video, desc: Anime Music video} - - {id: AnimePictures, cat: Other, desc: Anime Pictures} - - {id: AnimeRaw, cat: TV/Anime, desc: Anime Raw} - - {id: AnimeSubs, cat: TV/Anime, desc: Anime Subs} - - {id: Apps, cat: PC, desc: Apps} - - {id: AppsAndroid, cat: PC/Mobile-Android, desc: "Apps Android"} - - {id: AppsLinux, cat: PC, desc: "Apps Linux"} - - {id: AppsMac, cat: PC/Mac, desc: "Apps Mac"} - - {id: AppsWindows, cat: PC/0day, desc: "Apps Windows"} - - {id: Audiobooks, cat: Audio/Audiobook, desc: "Audio books"} - - {id: Bollywood, cat: Movies, desc: "Movies Bollywood"} - - {id: Books, cat: Books, desc: Books} - - {id: BooksAudiobooks, cat: Audio/Audiobook, desc: "Audio books"} - - {id: BooksComics, cat: Books/Comics, desc: Comics} - - {id: BooksEbooks, cat: Books/EBook, desc: Ebooks} - - {id: "BooksManga[English-translated]", cat: Books/Comics, desc: "Books Manga English-translated"} - - {id: "BooksManga[Raw]", cat: Books/Comics, desc: "Books Manga Raw"} - - {id: Comics, cat: Books/Comics, desc: Comics} - - {id: DVD, cat: Movies/DVD, desc: "Movies DVD"} - - {id: Documentary, cat: Movies/Other, desc: "Movies Documentary"} - - {id: DubbedMovies, cat: Movies, desc: "Movies Dubbed"} - - {id: EBooks, cat: Books/EBook, desc: Ebooks} - - {id: English-translated, cat: TV/Anime, desc: "Anime English-translated"} - - {id: Games, cat: Console, desc: "Games"} - - {id: GamesNDS, cat: Console/NDS, desc: NDS} - - {id: GamesOtherGames, cat: PC/Games, desc: "Games Other"} - - {id: GamesPCGames, cat: PC/Games, desc: "Games PC"} - - {id: GamesPS3, cat: Console/PS3, desc: PS3} - - {id: GamesPS4, cat: Console/PS4, desc: PS4} - - {id: GamesPSP, cat: Console/PSP, desc: PSP} - - {id: GamesWii, cat: Console/Wii, desc: Wii} - - {id: GamesXbox360, cat: Console/XBox 360, desc: Xbox360} - - {id: HighresMovies, cat: Movies/HD, desc: "Movies Highres"} - - {id: Linux, cat: PC, desc: "Apps Linux"} - - {id: Lossless, cat: Audio/Lossless, desc: "Music Lossless"} - - {id: MP3, cat: Audio/MP3, desc: "Music MP3"} - - {id: MP4, cat: Movies/HD, desc: "Movies MP4"} - - {id: Mac, cat: PC/Mac, desc: "Apps Mac"} - - {id: Movieclips, cat: Other, desc: "Movie clips"} - - {id: Movies, cat: Movies, desc: Movies} - - {id: Movies3DMovies, cat: Movies/3D, desc: "Movies 3D Movies"} - - {id: MoviesBollywood, cat: Movies, desc: "Movies Bollywood"} - - {id: MoviesDVD, cat: Movies/DVD, desc: "Movies DVD"} - - {id: MoviesDocumentary, cat: Movies/Other, desc: "Movies Documentary"} - - {id: MoviesDubbedMovies, cat: Movies, desc: "Movies Dubbed"} - - {id: MoviesHighresMovies, cat: Movies/HD, desc: "Movies Highres"} - - {id: MoviesMP4, cat: Movies/HD, desc: "Movies MP4"} - - {id: MoviesMusicvideos, cat: Audio/Video, desc: "Movies Music videos"} - - {id: MoviesMovieclips, cat: Other, desc: "Movies Movie clips"} - - {id: MoviesOtherMovies, cat: Movies/Other, desc: "Movies Other"} - - {id: MoviesUltraHD, cat: Movies/UHD, desc: "Movies UltraHD"} - - {id: Music, cat: Audio, desc: Music} - - {id: MusicLossless, cat: Audio/Lossless, desc: "Music Lossless"} - - {id: MusicMP3, cat: Audio/MP3, desc: "Music MP3"} - - {id: MusicMusicvideos, cat: Audio/Video, desc: "Music videos"} - - {id: MusicOthermusic, cat: Audio/Other, desc: "Music Other"} - - {id: Musicvideos, cat: Audio/Video, desc: "Music videos"} - - {id: NDS, cat: Console/NDS, desc: NDS} - - {id: Other, cat: Other, desc: Other} - - {id: OtherGames, cat: PC/Games, desc: "Games Other"} - - {id: OtherMovies, cat: Movies/Other, desc: "Movies Other"} - - {id: Othermusic, cat: Audio/Other, desc: "Music Other"} - - {id: PCGames, cat: PC/Games, desc: "Games PC"} - - {id: PS3, cat: Console/PS3, desc: PS3} - - {id: PS4, cat: Console/PS4, desc: PS4} - - {id: PSP, cat: Console/PSP, desc: PSP} - - {id: TV, cat: TV, desc: TV} - - {id: UltraHD, cat: Movies/UHD, desc: "Movies UltraHD"} - - {id: Video, cat: XXX/WEB-DL, desc: "Adult Video"} - - {id: Wii, cat: Console/Wii, desc: Wii} - - {id: Windows, cat: PC/0day, desc: "Apps Windows"} - - {id: XXXGames, cat: XXX/Other, desc: "Adult Games"} - - {id: XXXPictures, cat: XXX/ImageSet, desc: "Adult Pictures"} - - {id: XXXVideo, cat: XXX/WEB-DL, desc: "Adult Video"} - - {id: Xbox360, cat: Console/XBox 360, desc: Xbox360} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -search: - paths: - # 50 rows per page, however the All page only returns 49 actual results as the 50th is a duplicate of the 49th 8-/ - - path: "{{ if .Keywords }}search/?q={{ .Keywords }}{{ else }}17/All/{{ end }}" - - path: "{{ if .Keywords }}search/?page=2&q={{ .Keywords }}{{ else }}17/All/?page=2{{ end }}" - - rows: - selector: table.data > tbody > tr:has(a[href^="magnet:?xt="]) - - fields: - category: - selector: span > strong - filters: - - name: re_replace - args: ["[>| ]+", ""] - title: - selector: a.cellMainLink - details: - selector: a.cellMainLink - attribute: href - download: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-child(2) - date: - selector: td.timeago - filters: - - name: timeago - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kickasstorrents-ws.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kickasstorrents-ws.yml deleted file mode 100644 index 3b1b1964..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kickasstorrents-ws.yml +++ /dev/null @@ -1,122 +0,0 @@ ---- -id: kickasstorrents-ws -replaces: - - kickasstorrent-kathow -name: kickasstorrents.ws -description: "kickasstorrents.ws is a Public KickAssTorrent clone for MOVIES / TV / GENERAL" -language: en-US -type: public -encoding: UTF-8 -links: - - https://kickass.ws/ - - https://kickasstorrents.bz/ - - https://kkickass.com/ - - https://kkat.net/ - - https://kick4ss.com/ - - https://kickasst.net/ - - https://kickasstorrents.id/ - - https://thekat.cc/ - - https://kattracker.com/ -legacylinks: - - https://kickass.gg/ - - https://katcr.io/ - - https://thekat.nz/ - - https://thekat.se/ - - https://kat.how/ - - https://kat.li/ - - https://katcr.to/ # kickasstorrents-to proxy - - https://kickasstorrent.cr/ # kickasstorrents-to proxy - - https://kickasstorrents.unblockninja.com/ # currently kickasstorrents-to proxy - - https://kickass-kat.com/ - - https://kickass.sh/ - - https://kickasshydra.dev/ - -caps: - categorymappings: - - {id: applications, cat: PC, desc: Apps} - - {id: books, cat: Books, desc: Books} - - {id: games, cat: Console, desc: Games} - - {id: movies, cat: Movies, desc: Movies} - - {id: music, cat: Audio, desc: Music} - - {id: other, cat: Other, desc: Other} - - {id: tv, cat: TV, desc: TV} - - {id: xxx, cat: XXX, desc: XXX} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: time_add - options: - time_add: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -search: - paths: - - path: "{{ if .Keywords }}usearch/{{ .Keywords }}/{{ else }}new/{{ end }}?field={{ .Config.sort }}&sorder={{ .Config.type }}" - - headers: - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: table.data tr[id]:has(a[data-download]) - - fields: - category_optional: - selector: span[id^="cat_"] > strong > a - attribute: href - optional: true - filters: - - name: tolower - - name: trim - args: / - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}other{{ end }}" - title: - selector: a[class="cellMainLink"] - details: - selector: a[class="cellMainLink"] - attribute: href - download: - # the official site delivers mylink so we skip to the magnet URI - selector: td:nth-child(1) > div > a[data-download] - attribute: href - filters: - - name: querystring - args: url - size: - selector: td:nth-child(2) - date: - selector: td:nth-child(3) - filters: - - name: timeago - seeders: - selector: td:nth-child(4) - filters: - - name: replace - args: ["N/A", 0] - leechers: - selector: td:nth-child(5) - filters: - - name: replace - args: ["N/A", 0] - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinorun.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinorun.yml deleted file mode 100644 index b1ca230a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinorun.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: kinorun -name: Kinorun -description: "Kinorun is a RUSSIAN Semi-Private Torrent Tracker for 3X" -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - http://kinorun.top/ # site does not support https ERR_CONNECTION_TIMED_OUT - -caps: - categorymappings: - - {id: 9, cat: XXX, desc: "All sex"} - - {id: 51, cat: XXX, desc: "Anal Sex"} - - {id: 58, cat: XXX, desc: "Asian"} - - {id: 63, cat: XXX, desc: "BBW, FAT"} - - {id: 45, cat: XXX, desc: "Bdsm"} - - {id: 59, cat: XXX, desc: "Big tits"} - - {id: 66, cat: XXX, desc: "Black (Ebony)"} - - {id: 73, cat: XXX, desc: "Bukkake"} - - {id: 46, cat: XXX, desc: "Copro, Scat"} - - {id: 47, cat: XXX, desc: "Documentary"} - - {id: 70, cat: XXX, desc: "Fisting"} - - {id: 38, cat: XXX, desc: "Gay (homosexual)"} - - {id: 42, cat: XXX, desc: "Hentai, Cartoon"} - - {id: 43, cat: XXX, desc: "Home sex, Amateur"} - - {id: 56, cat: XXX, desc: "Incest"} - - {id: 34, cat: XXX, desc: "Lesbians"} - - {id: 60, cat: XXX, desc: "Mature 35"} - - {id: 67, cat: XXX, desc: "Midgets"} - - {id: 68, cat: XXX, desc: "Old 50"} - - {id: 55, cat: XXX, desc: "Orgy, group sex"} - - {id: 54, cat: XXX, desc: "Oral Sex"} - - {id: 77, cat: XXX, desc: "Porno pack"} - - {id: 76, cat: XXX, desc: "Porno parody"} - - {id: 69, cat: XXX, desc: "Pregnant"} - - {id: 75, cat: XXX, desc: "Retro porno "} - - {id: 52, cat: XXX, desc: "Russian porn"} - - {id: 44, cat: XXX, desc: "Shemales, Tranny"} - - {id: 61, cat: XXX, desc: "Solo, Toys"} - - {id: 32, cat: XXX, desc: "Special Movies"} - - {id: 39, cat: XXX, desc: "Teens 18"} - - {id: 74, cat: XXX, desc: "w/ Russian Subs"} - - {id: 79, cat: XXX, desc: "Erotic"} - - {id: 5, cat: XXX, desc: "Games"} - - {id: 14, cat: XXX, desc: "Cartoons"} - - {id: 24, cat: XXX, desc: "Other"} - - {id: 37, cat: XXX, desc: "Photo"} - - {id: 72, cat: XXX, desc: "Horses"} - - {id: 71, cat: XXX, desc: "Dogs"} - - {id: 30, cat: XXX, desc: "Animals"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: ip_filtering - type: info - label: "IP Filtering" - default: "Kinorun allows only certain IP addressess. The error 403 Forbidden: Parse error means your IP was not accepted." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin1.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(div.error) - - selector: title:contains("403") - test: - path: / - selector: a[href="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 gold, 4 noseeds, 5 notverified - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table.embedded > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category_optional: - selector: a[href^="browse.php?cat="] - attribute: href - optional: true - filters: - - name: querystring - args: cat - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}24{{ end }}" - title: - selector: a[href^="./torrent-"] - details: - selector: a[href^="details.php?id="] - attribute: href - _id: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: querystring - args: id - download: - text: "download.php?id={{ .Result._id }}" - description: - selector: a[href^="browse.php?cat="] img - attribute: alt - poster: - selector: td[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - date: - selector: td:nth-child(2) i - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - files: - selector: td:nth-child(3) - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - filters: - - name: split - args: ["|", 0] - leechers: - selector: td:nth-child(7) - filters: - - name: split - args: ["|", 1] - downloadvolumefactor: - case: - img[src="pic/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# TBDev diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinozal-magnet.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinozal-magnet.yml deleted file mode 100644 index 4c627bf5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinozal-magnet.yml +++ /dev/null @@ -1,308 +0,0 @@ ---- -id: kinozal-magnet -name: Kinozal (M) -description: "Kinozal is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV / MUSIC. This indexer uses magnet links." -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - https://kinozal.tv/ - - https://kinozal.guru/ -legacylinks: - - https://kinozal-tv.appspot.com/ - - http://kinozal.tv/ - - https://kinozal-guru.appspot.com/ - -caps: - categorymappings: - # TV - - {id: 1001, cat: TV, desc: "All TV Shows"} - - {id: 45, cat: TV, desc: "TV Series - Russian"} - - {id: 46, cat: TV, desc: "TV Series"} - # Movies - - {id: 1002, cat: Movies, desc: "All Movies"} - - {id: 8, cat: Movies, desc: "Movies - Comedy"} - - {id: 6, cat: Movies, desc: "Movies - Action / War"} - - {id: 15, cat: Movies, desc: "Movies - Thriller / Detective"} - - {id: 17, cat: Movies, desc: "Movies - Drama"} - - {id: 35, cat: Movies, desc: "Movies - Melodrama"} - - {id: 39, cat: Movies, desc: "Movies - Indian"} - - {id: 13, cat: Movies, desc: "Movies - Science Fiction"} - - {id: 14, cat: Movies, desc: "Movies - Fantasy"} - - {id: 24, cat: Movies, desc: "Movies - Horror / Mystery"} - - {id: 11, cat: Movies, desc: "Movies - Adventure"} - - {id: 10, cat: Movies, desc: "Movies - Russian"} - - {id: 9, cat: Movies, desc: "Movies - Historical"} - - {id: 47, cat: Movies, desc: "Movies - Asian"} - - {id: 18, cat: Movies, desc: "Movies - Documentaries"} - - {id: 37, cat: Movies, desc: "Movies - Sport"} - - {id: 12, cat: Movies, desc: "Movies - Kids / Family"} - - {id: 7, cat: Movies, desc: "Movies - Classic"} - - {id: 48, cat: Movies, desc: "Movies - Concerts"} - - {id: 49, cat: Movies, desc: "Movies - Shows / TV Shows"} - - {id: 50, cat: Movies, desc: "Movies - TV Show Mir"} - - {id: 38, cat: Movies, desc: "Movies - Theatre, Opera, Ballet"} - - {id: 16, cat: Movies, desc: "Movies - Erotica"} - # Cartoons - - {id: 1003, cat: TV, desc: "All Cartoons"} - - {id: 1003, cat: Movies, desc: "All Cartoons"} - - {id: 21, cat: TV, desc: "Cartoons"} - - {id: 21, cat: Movies, desc: "Cartoons"} - - {id: 22, cat: TV, desc: "Cartoons - Russian"} - - {id: 22, cat: Movies, desc: "Cartoons - Russian"} - - {id: 20, cat: TV/Anime, desc: "Cartoons - Anime"} - - {id: 20, cat: Movies/Other, desc: "Cartoons - Anime"} - # Music - - {id: 1004, cat: Audio, desc: "All Music"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: Audio, desc: "Music - Russian"} - - {id: 5, cat: Audio, desc: "Music - Collections"} - - {id: 42, cat: Audio, desc: "Music - Classical"} - # Other - - {id: 1006, cat: TV/Other, desc: "Shows, Concerts, Sports"} - - {id: 2, cat: Audio/Audiobook, desc: "Other - AudioBooks"} - - {id: 1, cat: Audio/Video, desc: "Other - Music Video's"} - - {id: 23, cat: PC/Games, desc: "Other - Games"} - - {id: 32, cat: PC, desc: "Other - Programs"} - - {id: 40, cat: Other, desc: "Other - Design / Graphics"} - - {id: 41, cat: Books, desc: "Other - Library"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: true - - name: addrussian - type: checkbox - label: Add RUSSIAN to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: true - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 1: seeders - 3: size - - name: type - type: select - label: Order requested from site - default: 0 - options: - 0: desc - 1: asc - - name: info_magnets - type: info - label: Magnets - default: "This tracker limits the number of torrents a user can download each day without paying. This indexer uses magnet links to bypass that limit, however this doesn't include the passkey, so any download/upload is not recorded. To download with torrents, use Kinozal." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.bx1:has(div.red) - message: - selector: div.bx1 div.red - test: - path: my.php - selector: a[href*="logout.php?hash4u="] - -download: - before: - path: get_srv_details.php - inputs: - action: 2 - id: "{{ .DownloadUri.Query.id }}" - infohash: - usebeforeresponse: true - hash: - selector: li:first-child - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: div.b - filters: - - name: trim - - name: validfilename - -search: - paths: - # http://kinozal.tv/browse.php?s=lucifer+2017&g=0&c=0&v=0&d=0&w=0&t=0&f=0 - - path: browse.php - # required for the appspot domain. - followredirect: true - keywordsfilters: -# - name: diacritics # 8686 -# args: replace - - name: re_replace - args: ["[^a-zA-Zа-яА-ЯёЁ0-9]+", " "] - - name: re_replace # S01 to 1 - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace # S01E01 to 1 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1 $2"] - inputs: - # multi cat is not supported. so defaulting to ALL - c: 0 - s: "{{ .Keywords }}" - # where 0 title, 1 person, 2 genres, 3 regular expression - g: 0 - # format 0 all - v: 0 - # released 0 all - d: 0 - # filter 0 all, 1 today, 2 yesterday, 3 in 3 days, 4 this week, 5 per month, 6-10 size rages, 11 gold, 12 silver - w: "{{ if .Config.freeleech }}11{{ else }}0{{ end }}" - t: "{{ .Config.sort }}" - f: "{{ .Config.type }}" - - rows: - selector: table > tbody > tr:has(td.bt) - - fields: - title: - selector: td.nam a[href^="/details.php?"] - filters: - # normalize to SXXEYY format - - name: replace - args: ["Кураж-Бамбей", "kurazh"] - - name: replace - args: ["Кубик в Кубе", "Kubik"] - - name: replace - args: ["Кравец", "Kravec"] - - name: replace - args: ["Пифагор", "Pifagor"] - - name: replace - args: ["Невафильм", "Nevafilm"] - - name: replace - args: ["АП", "AVO"] - - name: replace - args: ["ЛО", "VO"] - - name: replace - args: ["ЛД", "DVO"] - - name: replace - args: ["ЛМ", "MVO"] - - name: replace - args: ["ПО", "VO"] - - name: replace - args: ["ПД", "DVO"] - - name: replace - args: ["ПМ", "MVO"] - - name: replace - args: ["ДБ", "DUB"] - - name: replace - args: ["СТ", "Sub"] - - name: re_replace - args: ["\\((\\d+-*\\d*)\\s+[Сс]езоны?:?\\s+(?:(\\d+-*\\d*)\\s+(?:[Сс]ери[ийя]|выпуски?(?:ов)?)(?:.*\\d+)?)?\\)(.*)\\s+((?:[12][0-9]{3}-?){1,})(.*)", "$3 - S$1E$2 - $4 $5"] - - name: re_replace - args: ["\\((?:(\\d+-*\\d*)\\s+(?:[Сс]ери[ийя]|выпуски?(?:ов)?)(?:.*\\d+)?)?\\)(.*)\\s+((?:[12][0-9]{3}-?){1,})(.*)", "$2 - E$1 - $3 $4"] - - name: re_replace - args: ["(\\([А-Яа-яЁё\\W]+\\))|(^[А-Яа-яЁё\\W\\d]+\\/ ((?:[12][0-9]{3}-?){1,}))|(^[А-Яа-яЁё\\W\\d]+\\/ )|([а-яА-ЯЁё \\-]+,+)|([а-яА-ЯЁё]+)", "{{ if .Config.stripcyrillic }}{{ else }}$0{{ end }}"] - - name: replace - args: [" / ", " "] - - name: re_replace - args: ["^-\\s+", " "] - - name: re_replace - args: ["\\((\\d+[pi])\\)", "$1"] - - name: re_replace - args: ["(.*)(Blu-Ray\\s*(?:Disc|EUR|CEE)?)\\s*(\\d+[pi])", "$1 BR-DISK $3"] - - name: append - args: "{{ if .Config.addrussian }} - RUSSIAN{{ else }}{{ end }}" - - name: replace - args: ["-Rip", "Rip"] - - name: replace - args: ["WEB-DL", "WEBDL"] - - name: replace - args: ["WEBDLRip", "WEBDL"] - - name: replace - args: ["HDTVRip", "HDTV"] - details: - selector: td.nam a[href^="/details.php?"] - attribute: href - category: - selector: td.bt img - attribute: onclick - filters: - - name: re_replace - args: ["[^\\d+]", ""] - download: - selector: td.nam a[href^="/details.php?"] - attribute: href - filters: - - name: replace - args: ["details", "download"] - size: - selector: td:nth-child(4) - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - # dates come in four flavours: - date_day: - # now - # Today 09:10 - # Yesterday 13:04 - selector: td:nth-child(7):not(:contains(".")) - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: [" в", ""] - - name: replace - args: ["сейчас", "now"] - - name: replace - args: ["сегодня", "Today"] - - name: replace - args: ["вчера", "Yesterday"] - date_year: - # 24.10.2017 at 23:44 - selector: td:nth-child(7):contains(".") - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: [" в", ""] - - name: dateparse - args: "dd.MM.yyyy HH:mm" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - a.r1: 0 # gold - a.r2: 0.5 # silver - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinozal.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinozal.yml deleted file mode 100644 index aac98e44..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kinozal.yml +++ /dev/null @@ -1,289 +0,0 @@ ---- -id: kinozal -name: Kinozal -description: "Kinozal is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV / MUSIC. This indexer uses torrents." -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - https://kinozal.tv/ - - https://kinozal.guru/ -legacylinks: - - https://kinozal-tv.appspot.com/ - - http://kinozal.tv/ - - https://kinozal-guru.appspot.com/ - -caps: - categorymappings: - # TV - - {id: 1001, cat: TV, desc: "All TV Shows"} - - {id: 45, cat: TV, desc: "TV Series - Russian"} - - {id: 46, cat: TV, desc: "TV Series"} - # Movies - - {id: 1002, cat: Movies, desc: "All Movies"} - - {id: 8, cat: Movies, desc: "Movies - Comedy"} - - {id: 6, cat: Movies, desc: "Movies - Action / War"} - - {id: 15, cat: Movies, desc: "Movies - Thriller / Detective"} - - {id: 17, cat: Movies, desc: "Movies - Drama"} - - {id: 35, cat: Movies, desc: "Movies - Melodrama"} - - {id: 39, cat: Movies, desc: "Movies - Indian"} - - {id: 13, cat: Movies, desc: "Movies - Science Fiction"} - - {id: 14, cat: Movies, desc: "Movies - Fantasy"} - - {id: 24, cat: Movies, desc: "Movies - Horror / Mystery"} - - {id: 11, cat: Movies, desc: "Movies - Adventure"} - - {id: 10, cat: Movies, desc: "Movies - Russian"} - - {id: 9, cat: Movies, desc: "Movies - Historical"} - - {id: 47, cat: Movies, desc: "Movies - Asian"} - - {id: 18, cat: Movies, desc: "Movies - Documentaries"} - - {id: 37, cat: Movies, desc: "Movies - Sport"} - - {id: 12, cat: Movies, desc: "Movies - Kids / Family"} - - {id: 7, cat: Movies, desc: "Movies - Classic"} - - {id: 48, cat: Movies, desc: "Movies - Concerts"} - - {id: 49, cat: Movies, desc: "Movies - Shows / TV Shows"} - - {id: 50, cat: Movies, desc: "Movies - TV Show Mir"} - - {id: 38, cat: Movies, desc: "Movies - Theatre, Opera, Ballet"} - - {id: 16, cat: Movies, desc: "Movies - Erotica"} - # Cartoons - - {id: 1003, cat: TV, desc: "All Cartoons"} - - {id: 1003, cat: Movies, desc: "All Cartoons"} - - {id: 21, cat: TV, desc: "Cartoons"} - - {id: 21, cat: Movies, desc: "Cartoons"} - - {id: 22, cat: TV, desc: "Cartoons - Russian"} - - {id: 22, cat: Movies, desc: "Cartoons - Russian"} - - {id: 20, cat: TV/Anime, desc: "Cartoons - Anime"} - - {id: 20, cat: Movies/Other, desc: "Cartoons - Anime"} - # Music - - {id: 1004, cat: Audio, desc: "All Music"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: Audio, desc: "Music - Russian"} - - {id: 5, cat: Audio, desc: "Music - Collections"} - - {id: 42, cat: Audio, desc: "Music - Classical"} - # Other - - {id: 1006, cat: TV/Other, desc: "Shows, Concerts, Sports"} - - {id: 2, cat: Audio/Audiobook, desc: "Other - AudioBooks"} - - {id: 1, cat: Audio/Video, desc: "Other - Music Video's"} - - {id: 23, cat: PC/Games, desc: "Other - Games"} - - {id: 32, cat: PC, desc: "Other - Programs"} - - {id: 40, cat: Other, desc: "Other - Design / Graphics"} - - {id: 41, cat: Books, desc: "Other - Library"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: true - - name: addrussian - type: checkbox - label: Add RUSSIAN to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: true - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 1: seeders - 3: size - - name: type - type: select - label: Order requested from site - default: 0 - options: - 0: desc - 1: asc - - name: info_torrent - type: info - label: Torrents - default: "This tracker limits the number of torrents a user can download each day without paying. This indexer uses torrent files with the user's passkey, so any download/upload is recorded. If you receive errors when downloading torrent files, you may have reached this limit. To bypass the limit with magnet links, use Kinozal (M)." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.bx1:has(div.red) - message: - selector: div.bx1 div.red - test: - path: my.php - selector: a[href*="logout.php?hash4u="] - -search: - paths: - # http://kinozal.tv/browse.php?s=lucifer+2017&g=0&c=0&v=0&d=0&w=0&t=0&f=0 - - path: browse.php - # required for the appspot domain. - followredirect: true - keywordsfilters: -# - name: diacritics # 8686 -# args: replace - - name: re_replace - args: ["[^a-zA-Zа-яА-ЯёЁ0-9]+", " "] - - name: re_replace # S01 to 1 - args: ["(?i)\\bS0*(\\d+)\\b", "$1"] - - name: re_replace # S01E01 to 1 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "$1 $2"] - inputs: - # multi cat is not supported. so defaulting to ALL - c: 0 - s: "{{ .Keywords }}" - # where 0 title, 1 person, 2 genres, 3 regular expression - g: 0 - # format 0 all - v: 0 - # released 0 all - d: 0 - # filter 0 all, 1 today, 2 yesterday, 3 in 3 days, 4 this week, 5 per month, 6-10 size rages, 11 gold, 12 silver - w: "{{ if .Config.freeleech }}11{{ else }}0{{ end }}" - t: "{{ .Config.sort }}" - f: "{{ .Config.type }}" - - rows: - selector: table > tbody > tr:has(td.bt) - - fields: - title: - selector: td.nam a[href^="/details.php?"] - filters: - # normalize to SXXEYY format - - name: replace - args: ["Кураж-Бамбей", "kurazh"] - - name: replace - args: ["Кубик в Кубе", "Kubik"] - - name: replace - args: ["Кравец", "Kravec"] - - name: replace - args: ["Пифагор", "Pifagor"] - - name: replace - args: ["Невафильм", "Nevafilm"] - - name: replace - args: ["АП", "AVO"] - - name: replace - args: ["ЛО", "VO"] - - name: replace - args: ["ЛД", "DVO"] - - name: replace - args: ["ЛМ", "MVO"] - - name: replace - args: ["ПО", "VO"] - - name: replace - args: ["ПД", "DVO"] - - name: replace - args: ["ПМ", "MVO"] - - name: replace - args: ["ДБ", "DUB"] - - name: replace - args: ["СТ", "Sub"] - - name: re_replace - args: ["\\((\\d+-*\\d*)\\s+[Сс]езоны?:?\\s+(?:(\\d+-*\\d*)\\s+(?:[Сс]ери[ийя]|выпуски?(?:ов)?)(?:.*\\d+)?)?\\)(.*)\\s+((?:[12][0-9]{3}-?){1,})(.*)", "$3 - S$1E$2 - $4 $5"] - - name: re_replace - args: ["\\((?:(\\d+-*\\d*)\\s+(?:[Сс]ери[ийя]|выпуски?(?:ов)?)(?:.*\\d+)?)?\\)(.*)\\s+((?:[12][0-9]{3}-?){1,})(.*)", "$2 - E$1 - $3 $4"] - - name: re_replace - args: ["(\\([А-Яа-яЁё\\W]+\\))|(^[А-Яа-яЁё\\W\\d]+\\/ ((?:[12][0-9]{3}-?){1,}))|(^[А-Яа-яЁё\\W\\d]+\\/ )|([а-яА-ЯЁё \\-]+,+)|([а-яА-ЯЁё]+)", "{{ if .Config.stripcyrillic }}{{ else }}$0{{ end }}"] - - name: replace - args: [" / ", " "] - - name: re_replace - args: ["^-\\s+", " "] - - name: re_replace - args: ["\\((\\d+[pi])\\)", "$1"] - - name: re_replace - args: ["(.*)(Blu-Ray\\s*(?:Disc|EUR|CEE)?)\\s*(\\d+[pi])", "$1 BR-DISK $3"] - - name: append - args: "{{ if .Config.addrussian }} - RUSSIAN{{ else }}{{ end }}" - - name: replace - args: ["-Rip", "Rip"] - - name: replace - args: ["WEB-DL", "WEBDL"] - - name: replace - args: ["WEBDLRip", "WEBDL"] - - name: replace - args: ["HDTVRip", "HDTV"] - details: - selector: td.nam a[href^="/details.php?"] - attribute: href - category: - selector: td.bt img - attribute: onclick - filters: - - name: re_replace - args: ["[^\\d+]", ""] - download: - selector: td.nam a[href^="/details.php?"] - attribute: href - filters: - - name: replace - args: ["details", "download"] - size: - selector: td:nth-child(4) - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - # dates come in four flavours: - date_day: - # now - # Today 09:10 - # Yesterday 13:04 - selector: td:nth-child(7):not(:contains(".")) - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: [" в", ""] - - name: replace - args: ["сейчас", "now"] - - name: replace - args: ["сегодня", "Today"] - - name: replace - args: ["вчера", "Yesterday"] - date_year: - # 24.10.2017 at 23:44 - selector: td:nth-child(7):contains(".") - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: [" в", ""] - - name: dateparse - args: "dd.MM.yyyy HH:mm" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - a.r1: 0 # gold - a.r2: 0.5 # silver - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/korsar.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/korsar.yml deleted file mode 100644 index 07eecdf2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/korsar.yml +++ /dev/null @@ -1,616 +0,0 @@ ---- -id: korsar -name: Korsar -description: "Korsar is a RUSSIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: private -encoding: UTF-8 -links: - - http://filbi1976.org/ -legacylinks: - - https://korsar.tv/ - - http://korsar.info/ - - https://korsar.info/ - - http://korsar.tv/ - - http://korsar.rest/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Все Кинофильмы"} - - {id: 153, cat: Movies, desc: "Кинофильмы - Кино времён СССР"} - - {id: 656, cat: Movies, desc: "Кинофильмы - Детские фильмы времён СССР"} - - {id: 186, cat: Movies/HD, desc: "Кинофильмы - Кино времён СССР (HD Video)"} - - {id: 657, cat: Movies/HD, desc: "Кинофильмы - Детские фильмы времён СССР (HD Video)"} - - {id: 49, cat: Movies, desc: "Кинофильмы - Наше современное кино"} - - {id: 203, cat: Movies/HD, desc: "Кинофильмы - Наше современное кино (HD Video)"} - - {id: 268, cat: Movies, desc: "Кинофильмы - Зарубежное кино"} - - {id: 654, cat: Movies, desc: "Кинофильмы - Классика зарубежного кино (фильмы до 1980 года)"} - - {id: 658, cat: Movies, desc: "Кинофильмы - Зарубежное кино (1981-1990)"} - - {id: 659, cat: Movies, desc: "Кинофильмы - Зарубежное кино (1991-2000)"} - - {id: 660, cat: Movies, desc: "Кинофильмы - Зарубежное кино (2001-2010)"} - - {id: 661, cat: Movies, desc: "Кинофильмы - Зарубежное кино (2011-2023)"} - - {id: 944, cat: Movies, desc: "Кинофильмы - Зарубежное кино (2024-2030)"} - - {id: 676, cat: Movies, desc: "Кинофильмы - Зарубежное кино (Сборники фильмов)"} - - {id: 913, cat: Movies, desc: "Кинофильмы - Зарубежное кино (КПК Video)"} - - {id: 205, cat: Movies/HD, desc: "Кинофильмы - Зарубежное кино (HD Video)"} - - {id: 655, cat: Movies/HD, desc: "Кинофильмы - Классика зарубежного кино (фильмы до 1980 года / HD Video)"} - - {id: 662, cat: Movies/HD, desc: "Кинофильмы - Зарубежное кино (1981-1990 / HD Video)"} - - {id: 663, cat: Movies/HD, desc: "Кинофильмы - Зарубежное кино (1991-2000 / HD Video)"} - - {id: 664, cat: Movies/HD, desc: "Кинофильмы - Зарубежное кино (2001-2010 / HD Video)"} - - {id: 665, cat: Movies/HD, desc: "Кинофильмы - Зарубежное кино (2011-2023 / HD Video)"} - - {id: 943, cat: Movies/3D, desc: "Кинофильмы - Зарубежное кино (2024-2030 / HD Video)"} - - {id: 666, cat: Movies/HD, desc: "Кинофильмы - Зарубежное кино (Сборники фильмов / HD Video)"} - - {id: 922, cat: Movies, desc: "Кинофильмы - Фильмы Ближнего Зарубежья"} - - {id: 923, cat: Movies/HD, desc: "Кинофильмы - Фильмы Ближнего Зарубежья (HD Video)"} - - {id: 838, cat: Movies/UHD, desc: "Кинофильмы - Фильмы 4К (UHD 4K)"} - - {id: 848, cat: Movies, desc: "Кинофильмы - Фильмы HEVC (HEVC Video)"} - - {id: 837, cat: Movies/3D, desc: "Кинофильмы - Фильмы 3D (3D Video)"} - - - {id: 33, cat: TV, desc: "Все Сериалы"} - - {id: 35, cat: TV, desc: "Сериалы - Отечественные сериалы"} - - {id: 266, cat: TV/HD, desc: "Сериалы - Отечественные сериалы (HD Video)"} - - {id: 34, cat: TV, desc: "Сериалы - Зарубежные сериалы"} - - {id: 267, cat: TV/HD, desc: "Сериалы - Зарубежные сериалы (HD Video)"} - - {id: 916, cat: TV, desc: "Сериалы - ближнего зарубежья"} - - {id: 917, cat: TV/HD, desc: "Сериалы ближнего зарубежья (HD Video)"} - - {id: 800, cat: TV, desc: "Сериалы - Зарубежные сериалы (Сборники сезонов)"} - - {id: 801, cat: TV/HD, desc: "Сериалы - Зарубежные сериалы (Сборники сезонов / HD Video)"} - - {id: 906, cat: TV/UHD, desc: "Сериалы - Зарубежные сериалы (4K)"} - - {id: 910, cat: TV, desc: "Сериалы - Зарубежные сериалы (HEVC Video)"} - - - {id: 46, cat: TV/Documentary, desc: "Все Документалистика"} - - {id: 77, cat: TV/Documentary, desc: "Документалистика - Документальные фильмы и телепередачи"} - - {id: 84, cat: TV/Documentary, desc: "Документалистика - Биографии. Личности и кумиры"} - - {id: 92, cat: TV/Documentary, desc: "Документалистика - Космос"} - - {id: 86, cat: TV/Documentary, desc: "Документалистика - Флора и фауна"} - - {id: 85, cat: TV/Documentary, desc: "Документалистика - Научно-популярные фильмы"} - - {id: 148, cat: TV/Documentary, desc: "Документалистика - Познавательные фильмы"} - - {id: 187, cat: TV/Documentary, desc: "Документалистика - Путешествия и туризм"} - - {id: 88, cat: TV/Documentary, desc: "Документалистика - BBC, Discovery, National Geographic"} - - {id: 692, cat: TV/Documentary, desc: "Документалистика - Военное дело"} - - {id: 91, cat: TV/Documentary, desc: "Документалистика - История: Древний мир / Античность / Средневековье"} - - {id: 693, cat: TV/Documentary, desc: "Документалистика - КВН, Ток-Шоу, ТВ-Шоу"} - - {id: 694, cat: TV/Documentary, desc: "Документалистика - Интеллектуальные игры и викторины"} - - {id: 696, cat: TV/Documentary, desc: "Документалистика - Сатирики, юмористы, приколы и юмор"} - - {id: 700, cat: TV/Documentary, desc: "Документалистика - Естествознание, наука и техника"} - - {id: 698, cat: TV/Documentary, desc: "Документалистика - Аудио и видео ролики (Приколы и юмор)"} - - {id: 47, cat: TV/Documentary, desc: "Документалистика - Документальные фильмы и телепередачи (HD Video)"} - - {id: 283, cat: TV/Documentary, desc: "Документалистика - Биографии. Личности и кумиры (HD Video)"} - - {id: 290, cat: TV/Documentary, desc: "Документалистика - Военное дело (HD Video)"} - - {id: 291, cat: TV/Documentary, desc: "Документалистика - Естествознание, наука и техника (HD Video)"} - - {id: 288, cat: TV/Documentary, desc: "Документалистика - Путешествия и туризм (HD Video)"} - - {id: 285, cat: TV/Documentary, desc: "Документалистика - Флора и фауна (HD Video)"} - - {id: 284, cat: TV/Documentary, desc: "Документалистика - Космос (HD Video)"} - - {id: 573, cat: TV/Documentary, desc: "Документалистика - Познавательные фильмы (HD Video)"} - - {id: 286, cat: TV/Documentary, desc: "Документалистика - Научно-популярные фильмы (HD Video)"} - - {id: 289, cat: TV/Documentary, desc: "Документалистика - BBC, Discovery, National Geographic (HD Video)"} - - {id: 690, cat: TV/Documentary, desc: "Документалистика - КВН, ТВ-Шоу, Ток-Шоу (HD Video)"} - - {id: 695, cat: TV/Documentary, desc: "Документалистика - Интеллектуальные игры и викторины (HD Video)"} - - {id: 697, cat: TV/Documentary, desc: "Документалистика - Сатирики, юмористы, приколы и юмор (HD Video)"} - - {id: 701, cat: TV/Documentary, desc: "Документалистика - История: Древний мир / Античность / Средневековье (HD Video)"} - - {id: 699, cat: TV/Documentary, desc: "Документалистика - Аудио и видео ролики (Приколы и юмор / HD Video)"} - - {id: 689, cat: TV/Documentary, desc: "Документалистика - Документальные (3D Video)"} - - {id: 893, cat: TV/Documentary, desc: "Документалистика - Документальные (4К)"} - - {id: 79, cat: TV/Documentary, desc: "Документалистика - Вера и религия"} - - {id: 98, cat: TV/Documentary, desc: "Документалистика - Христианство"} - - {id: 99, cat: TV/Documentary, desc: "Документалистика - Ислам"} - - {id: 100, cat: TV/Documentary, desc: "Документалистика - Религии Индии, Тибета и Восточной Азии"} - - - {id: 73, cat: TV/Anime, desc: "Все Мультипликация"} - - {id: 74, cat: TV/Anime, desc: "Мультипликация - Мультфильмы"} - - {id: 292, cat: TV/Anime, desc: "Мультипликация - Зарубежные мультфильмы (HD Video)"} - - {id: 874, cat: TV/Anime, desc: "Мультипликация - Отечественные мультфильмы (HD Video)"} - - {id: 299, cat: TV/Anime, desc: "Мультипликация - Зарубежные мультфильмы"} - - {id: 297, cat: TV/Anime, desc: "Мультипликация - Отечественные мультфильмы"} - - {id: 301, cat: TV/Anime, desc: "Мультипликация - Сборники зарубежных мультфильмов (HD Video)"} - - {id: 876, cat: TV/Anime, desc: "Мультипликация - Сборники зарубежных мультфильмов"} - - {id: 877, cat: TV/Anime, desc: "Мультипликация - Сборники отечественных мультфильмов (HD Video)"} - - {id: 875, cat: TV/Anime, desc: "Мультипликация - Сборники отечественных мультфильмов<"} - - {id: 294, cat: TV/Anime, desc: "Мультипликация - Отечественные (DVD)"} - - {id: 295, cat: TV/Anime, desc: "Мультипликация - Иностранные (DVD)"} - - {id: 688, cat: TV/Anime, desc: "Мультипликация - Мультфильмы (3D Video)"} - - {id: 807, cat: TV/Anime, desc: "Мультипликация - Мультфильмы (60 FPS)"} - - {id: 841, cat: TV/Anime, desc: "Мультипликация - Мультфильмы (UHD 4K)"} - - {id: 867, cat: TV/Anime, desc: "Мультипликация - Мультфильмы (HEVC Video)"} - - {id: 75, cat: TV/Anime, desc: "Мультипликация - Мультсериалы"} - - {id: 872, cat: TV/Anime, desc: "Мультипликация - Зарубежные мультсериалы (HD Video)"} - - {id: 878, cat: TV/Anime, desc: "Мультипликация - Зарубежные мультсериалы"} - - {id: 879, cat: TV/Anime, desc: "Мультипликация - Отечественные мультсериалы (HD Video)"} - - {id: 880, cat: TV/Anime, desc: "Мультипликация - Отечественные мультсериалы"} - - {id: 76, cat: TV/Anime, desc: "Мультипликация - Аниме"} - - {id: 302, cat: TV/Anime, desc: "Мультипликация - Аниме"} - - {id: 303, cat: TV/Anime, desc: "Мультипликация - Аниме (DVD)"} - - {id: 304, cat: TV/Anime, desc: "Мультипликация - Аниме (HD Video)"} - - - {id: 108, cat: TV, desc: "Все Театр"} - - {id: 109, cat: TV, desc: "Бенефис. Мастера искусств отечественного Театра и Кино"} - - {id: 130, cat: TV, desc: "Театр - Фильмы-спектакли, театральные постановки"} - - - {id: 319, cat: TV/Sport, desc: "Все Спорт"} - - {id: 320, cat: TV/Sport, desc: "Спорт - Спортивные турниры, фильмы и передачи"} - - {id: 321, cat: TV/Sport, desc: "Спорт - Автоспорт"} - - {id: 322, cat: TV/Sport, desc: "Спорт - Мотоспорт"} - - {id: 323, cat: TV/Sport, desc: "Спорт - Формула 1 "} - - {id: 324, cat: TV/Sport, desc: "Спорт - Велоспорт"} - - {id: 325, cat: TV/Sport, desc: "Спорт - Волейбол/Гандбол"} - - {id: 326, cat: TV/Sport, desc: "Спорт - Бокс"} - - {id: 327, cat: TV/Sport, desc: "Спорт - Единоборства"} - - {id: 328, cat: TV/Sport, desc: "Спорт - Американский футбол"} - - {id: 329, cat: TV/Sport, desc: "Спорт - Регби"} - - {id: 330, cat: TV/Sport, desc: "Спорт - Бейсбол"} - - {id: 331, cat: TV/Sport, desc: "Спорт - Теннис"} - - {id: 332, cat: TV/Sport, desc: "Спорт - Биатлон"} - - {id: 333, cat: TV/Sport, desc: "Спорт - Фигурное катание"} - - {id: 334, cat: TV/Sport, desc: "Спорт - Экстрим"} - - {id: 335, cat: TV/Sport, desc: "Спорт - Спорт (видео)"} - - {id: 338, cat: TV/Sport, desc: "Спорт - Футбол"} - - {id: 680, cat: TV/Sport, desc: "Спорт - Чемпионаты футбольных держав (Англия, Германия, Испания, Италия, Франция)"} - - {id: 681, cat: TV/Sport, desc: "Спорт - Еврокубки"} - - {id: 682, cat: TV/Sport, desc: "Спорт - Чемпионаты Европы"} - - {id: 683, cat: TV/Sport, desc: "Спорт - Чемпионаты мира"} - - {id: 684, cat: TV/Sport, desc: "Спорт - Международные турниры"} - - {id: 685, cat: TV/Sport, desc: "Спорт - Обзорные и аналитические передачи"} - - {id: 686, cat: TV/Sport, desc: "Спорт - Россия"} - - {id: 339, cat: TV/Sport, desc: "Спорт - Баскетбол"} - - {id: 340, cat: TV/Sport, desc: "Спорт - Хоккей"} - - - {id: 32, cat: Audio, desc: "Все Музыка"} - - {id: 134, cat: Audio/Video, desc: "Музыка - Музыка (SD Video)"} - - {id: 544, cat: Audio/Video, desc: "Музыка - Концерты"} - - {id: 545, cat: Audio/Video, desc: "Музыка - Видеоклипы"} - - {id: 36, cat: Audio/Video, desc: "Музыка - Музыка (HD Video)"} - - {id: 542, cat: Audio/Video, desc: "Музыка - Концерты (HD Video)"} - - {id: 543, cat: Audio/Video, desc: "Музыка - Видеоклипы (HD Video)"} - - {id: 483, cat: Audio, desc: "Музыка - Популярная музыка"} - - {id: 490, cat: Audio, desc: "Музыка - Зарубежная поп-музыка (Lossless)"} - - {id: 546, cat: Audio/MP3, desc: "Музыка - Зарубежная поп-музыка (Lossy)"} - - {id: 620, cat: Audio/Lossless, desc: "Музыка - Eurodance, Euro-House, Technopop, Disco, Italo-Disco (Lossless)"} - - {id: 621, cat: Audio/MP3, desc: "Музыка - Eurodance, Euro-House, Technopop, Disco, Italo-Disco (Lossy)"} - - {id: 484, cat: Audio/Lossless, desc: "Музыка - Отечественная поп-музыка (Lossless)"} - - {id: 547, cat: Audio/MP3, desc: "Музыка - Отечественная поп-музыка (Lossy)"} - - {id: 523, cat: Audio/Lossless, desc: "Музыка - Зарубежные сборники (Lossless)"} - - {id: 548, cat: Audio/MP3, desc: "Музыка - Зарубежные сборники (Lossy)"} - - {id: 522, cat: Audio/Lossless, desc: "Музыка - Отечественные сборники (Lossless)"} - - {id: 498, cat: Audio/MP3, desc: "Музыка - Отечественные сборники (Lossy)"} - - {id: 788, cat: Audio, desc: "Музыка - Популярная музыка"} - - {id: 787, cat: Audio/MP3, desc: "Музыка - Сборники 50х50 (Lossy)"} - - {id: 505, cat: Audio, desc: "Музыка - Рок-музыка"} - - {id: 506, cat: Audio/Lossless, desc: "Музыка - Зарубежный Rock (Lossless)"} - - {id: 537, cat: Audio/MP3, desc: "Музыка - Зарубежный Rock (Lossy)"} - - {id: 507, cat: Audio/Lossless, desc: "Музыка - Зарубежный Metal (Lossless)"} - - {id: 538, cat: Audio/MP3, desc: "Музыка - Зарубежный Metal (Lossy)"} - - {id: 588, cat: Audio/Lossless, desc: "Музыка - Зарубежные Alternative, Punk, Independent (Lossless)"} - - {id: 589, cat: Audio/MP3, desc: "Музыка - Зарубежные Alternative, Punk, Independent (Lossy)"} - - {id: 539, cat: Audio/MP3, desc: "Музыка - Отечественный Рок (Lossy)"} - - {id: 541, cat: Audio/MP3, desc: "Музыка - Сборники (Lossy)"} - - {id: 524, cat: Audio, desc: "Музыка - Блюзовая и Джазовая музыка"} - - {id: 527, cat: Audio/Lossless, desc: "Музыка - Зарубежный блюз (Lossless)"} - - {id: 528, cat: Audio/MP3, desc: "Музыка - Зарубежный блюз (Lossy)"} - - {id: 642, cat: Audio/Lossless, desc: "Музыка - Зарубежный джаз (Lossless)"} - - {id: 643, cat: Audio/MP3, desc: "Музыка - Зарубежный джаз (Lossy)"} - - {id: 529, cat: Audio/Lossless, desc: "Музыка - Сборники (Lossless)"} - - {id: 530, cat: Audio/MP3, desc: "Музыка - Сборники (Lossy)"} - - {id: 531, cat: Audio, desc: "Музыка - New Country"} - - {id: 532, cat: Audio/Lossless, desc: "Музыка - Зарубежное New Country (Lossless)"} - - {id: 533, cat: Audio/MP3, desc: "Музыка - Зарубежное New Country (Lossy)"} - - {id: 534, cat: Audio/Lossless, desc: "Музыка - Сборники (Lossless)"} - - {id: 535, cat: Audio/MP3, desc: "Музыка - Сборники (Lossy)"} - - {id: 551, cat: Audio, desc: "Музыка - Электронная музыка"} - - {id: 552, cat: Audio/Lossless, desc: "Музыка - Chillout, Lounge, Downtempo, Trip-Hop (Lossless) "} - - {id: 553, cat: Audio/MP3, desc: "Музыка - Chillout, Lounge, Downtempo, Trip-Hop (Lossy)"} - - {id: 624, cat: Audio/Lossless, desc: "Музыка - Drum & ass, Jungle, Breakbeat, Dubstep, IDM, Trap (Lossless)"} - - {id: 623, cat: Audio/MP3, desc: "Музыка - Drum & ass, Jungle, Breakbeat, Dubstep, IDM, Trap (Lossy)"} - - {id: 785, cat: Audio/Lossless, desc: "Музыка - Trance, Goa Trance, Psy-Trance, PsyChill, Ambient, Full On, Dub (Lossless)"} - - {id: 786, cat: Audio/MP3, desc: "Музыка - Trance, Goa Trance, Psy-Trance, PsyChill, Ambient, Full On, Dub (Lossy)"} - - {id: 571, cat: Audio/Lossless, desc: "Музыка - Progressive Trance, Progressive House, Tech-House, Deep-House, Electro, Electro-House, House (Lossless)"} - - {id: 572, cat: Audio/MP3, desc: "Музыка - Progressive Trance, Progressive House, Tech-House, Deep-House, Electro, Electro-House, House (Lossy)"} - - {id: 843, cat: Audio/Lossless, desc: "Музыка - Dj-миксы и радиошоу, Dance, Club, Hands Up (Lossless)"} - - {id: 844, cat: Audio/MP3, desc: "Музыка - Dj-миксы и радиошоу, Dance, Club, Hands Up (Lossy)"} - - {id: 846, cat: Audio/Lossless, desc: "Музыка - Darkwave, Dark Electro, Aggrotech, Industrial (Lossless)"} - - {id: 845, cat: Audio/MP3, desc: "Музыка - Darkwave, Dark Electro, Aggrotech, Industrial (Lossy)"} - - {id: 644, cat: Audio/Lossless, desc: "Музыка - Сборники (Lossless)"} - - {id: 645, cat: Audio/MP3, desc: "Музыка - Сборники (Lossy)"} - - {id: 771, cat: Audio, desc: "Музыка - Psychedelic Trance"} - - {id: 772, cat: Audio/Lossless, desc: "Музыка - Psychedelic Trance (Lossless)"} - - {id: 773, cat: Audio/MP3, desc: "Музыка - Psychedelic Trance (Lossy)"} - - {id: 344, cat: Audio, desc: "Музыка - Рэп, Хип-Хоп, R'n'B"} - - {id: 556, cat: Audio/Lossless, desc: "Музыка - Зарубежный (Lossless)"} - - {id: 348, cat: Audio/MP3, desc: "Музыка - Зарубежный (Lossy)"} - - {id: 557, cat: Audio/Lossless, desc: "Музыка - Отечественный (Lossless)"} - - {id: 345, cat: Audio/MP3, desc: "Музыка - Отечественный (Lossy)"} - - {id: 633, cat: Audio/Lossless, desc: "Музыка - Сборники (Lossless)"} - - {id: 634, cat: Audio/MP3, desc: "Музыка - Сборники (Lossy)"} - - {id: 622, cat: Audio, desc: "Музыка - Reggae, Ska, Dub"} - - {id: 629, cat: Audio/Lossless, desc: "Музыка - Зарубежный (Lossless)"} - - {id: 630, cat: Audio/MP3, desc: "Музыка - Зарубежный (Lossy)"} - - {id: 635, cat: Audio/Lossless, desc: "Музыка - Отечественный (Lossless)"} - - {id: 636, cat: Audio/MP3, desc: "Музыка - Отечественный (Lossy)"} - - {id: 631, cat: Audio/Lossless, desc: "Музыка - Сборники (Lossless)"} - - {id: 632, cat: Audio/MP3, desc: "Музыка - Сборники (Lossy)"} - - {id: 387, cat: Audio, desc: "Музыка - Музыка других жанров"} - - {id: 560, cat: Audio/Lossless, desc: "Музыка - Зарубежная музыка других жанров (Lossless)"} - - {id: 561, cat: Audio/MP3, desc: "Музыка - Зарубежная музыка других жанров (Lossy)"} - - {id: 570, cat: Audio/Lossless, desc: "Музыка - Сборники смешанных жанров (lossless)"} - - {id: 569, cat: Audio/MP3, desc: "Музыка - Сборники смешанных жанров (lossy)"} - - {id: 889, cat: Audio/Lossless, desc: "Музыка - Сборники песен для детей (Lossless)"} - - {id: 562, cat: Audio/MP3, desc: "Музыка - Сборники песен для детей (Lossy)"} - - {id: 362, cat: Audio, desc: "Музыка - Саундтреки"} - - {id: 555, cat: Audio/Lossless, desc: "Музыка - Саундтреки к зарубежным фильмам (Lossless)"} - - {id: 368, cat: Audio/MP3, desc: "Музыка - Саундтреки к зарубежным фильмам (Lossy)"} - - {id: 554, cat: Audio/Lossless, desc: "Музыка - Саундтреки к отечественным фильмам (Lossless)"} - - {id: 366, cat: Audio/MP3, desc: "Музыка - Саундтреки к отечественным фильмам (Lossy)"} - - {id: 376, cat: Audio, desc: "Музыка - Шансон, Авторская и Военная песня"} - - {id: 558, cat: Audio/Lossless, desc: "Музыка - Отечественный шансон (Lossless)"} - - {id: 378, cat: Audio/MP3, desc: "Музыка - Отечественный шансон (Lossy)"} - - {id: 379, cat: Audio/Lossless, desc: "Музыка - Сборники отечественного шансона (Lossless)"} - - {id: 559, cat: Audio/MP3, desc: "Музыка - Сборники отечественного шансона (Lossy)"} - - {id: 564, cat: Audio/Lossless, desc: "Музыка - Военная песня (Lossless)"} - - {id: 380, cat: Audio/MP3, desc: "Музыка - Военная песня (Lossy)"} - - {id: 565, cat: Audio/Lossless, desc: "Музыка - Авторская песня (Lossless)"} - - {id: 383, cat: Audio/MP3, desc: "Музыка - Авторская песня (Lossy)"} - - {id: 779, cat: Audio, desc: "Музыка - New Age, Relax, Meditative & Flamenсо"} - - {id: 780, cat: Audio/Lossless, desc: "Музыка - Фламенко и акустическая гитара (lossless)"} - - {id: 781, cat: Audio/MP3, desc: "Музыка - Фламенко и акустическая гитара (lossy)"} - - {id: 341, cat: Audio, desc: "Музыка - Классическая и современная академическая музыка"} - - {id: 549, cat: Audio/Lossless, desc: "Музыка - Классическая и современная академическая музыка (lossless)"} - - {id: 550, cat: Audio/MP3, desc: "Музыка - Классическая и современная академическая музыка (lossy)"} - - {id: 536, cat: Audio, desc: "Музыка - Многоканальная музыка, Hi-Res форматы, оцифровки"} - - {id: 653, cat: Audio, desc: "Музыка - Музыка (HD Audio)"} - - - {id: 45, cat: Audio/Audiobook, desc: "Все Аудиокниги"} - - {id: 141, cat: Audio/Audiobook, desc: "Аудиокниги - Радиоспектакли, история, мемуары"} - - {id: 389, cat: Audio/Audiobook, desc: "Аудиокниги - Радиоспектакли и литературные чтения"} - - {id: 388, cat: Audio/Audiobook, desc: "Аудиокниги - Жизнь замечательных людей"} - - {id: 390, cat: Audio/Audiobook, desc: "Аудиокниги - Историческая книга"} - - {id: 48, cat: Audio/Audiobook, desc: "Аудиокниги - Фантастика, фэнтези, мистика, ужасы, фанфики"} - - {id: 391, cat: Audio/Audiobook, desc: "Аудиокниги - Зарубежная фантастика, фэнтези, мистика, ужасы, фанфики"} - - {id: 392, cat: Audio/Audiobook, desc: "Аудиокниги - Российская фантастика, фэнтези, мистика, ужасы, фанфики"} - - {id: 126, cat: Audio/Audiobook, desc: "Аудиокниги - Художественная литература"} - - {id: 394, cat: Audio/Audiobook, desc: "Аудиокниги - Поэзия"} - - {id: 395, cat: Audio/Audiobook, desc: "Аудиокниги - Зарубежная литература"} - - {id: 396, cat: Audio/Audiobook, desc: "Аудиокниги - Русская литература"} - - {id: 397, cat: Audio/Audiobook, desc: "Аудиокниги - Детская литература"} - - {id: 398, cat: Audio/Audiobook, desc: "Аудиокниги - Детективы, приключения, триллеры, боевики"} - - {id: 399, cat: Audio/Audiobook, desc: "Аудиокниги - Религии"} - - {id: 400, cat: Audio/Audiobook, desc: "Аудиокниги - Православие"} - - {id: 401, cat: Audio/Audiobook, desc: "Аудиокниги - Ислам"} - - {id: 402, cat: Audio/Audiobook, desc: "Аудиокниги - Другие традиционные религии"} - - {id: 404, cat: Audio/Audiobook, desc: "Аудиокниги - Прочая литература"} - - {id: 405, cat: Audio/Audiobook, desc: "Аудиокниги - Учебная и научно-популярная литература"} - - {id: 406, cat: Audio/Audiobook, desc: "Аудиокниги - Аудиокниги в lossless-форматах"} - - {id: 407, cat: Audio/Audiobook, desc: "Аудиокниги - Бизнес"} - - {id: 408, cat: Audio/Audiobook, desc: "Аудиокниги - Разное"} - - - {id: 37, cat: Books, desc: "Все Книги и журналы"} - - {id: 471, cat: Books, desc: "Книги и журналы - Книги"} - - {id: 472, cat: Books, desc: "Книги и журналы - Кино, театр, ТВ, мультипликация"} - - {id: 473, cat: Books, desc: "Книги и журналы - Рисунок, графический дизайн"} - - {id: 474, cat: Books, desc: "Книги и журналы - Фото и видеосъемка"} - - {id: 475, cat: Books, desc: "Книги и журналы - Журналы и газеты (общий раздел)"} - - {id: 479, cat: Books, desc: "Книги и журналы - Мода. Стиль. Этикет"} - - {id: 678, cat: Books, desc: "Книги и журналы - Православие"} - - {id: 420, cat: Books, desc: "Книги и журналы - Для детей, родителей и учителей"} - - {id: 421, cat: Books, desc: "Книги и журналы - Учебная литература для детского сада и начальной школы (до 4 класса)"} - - {id: 422, cat: Books, desc: "Книги и журналы - Учебная литература для старших классов (5-11 класс)"} - - {id: 423, cat: Books, desc: "Книги и журналы - Учителям и педагогам"} - - {id: 424, cat: Books, desc: "Книги и журналы - Научно-популярная и познавательная литература (для детей)"} - - {id: 425, cat: Books, desc: "Книги и журналы - Досуг и творчество"} - - {id: 426, cat: Books, desc: "Книги и журналы - Воспитание и развитие"} - - {id: 427, cat: Books, desc: "Книги и журналы - Худ. лит-ра для дошкольников и младших классов"} - - {id: 428, cat: Books, desc: "Книги и журналы - Худ. лит-ра для средних и старших классов"} - - {id: 429, cat: Books, desc: "Книги и журналы - Спорт, физическая культура, боевые искусства"} - - {id: 430, cat: Books, desc: "Книги и журналы - Футбол"} - - {id: 431, cat: Books, desc: "Книги и журналы - Хоккей"} - - {id: 432, cat: Books, desc: "Книги и журналы - Игровые виды спорта"} - - {id: 433, cat: Books, desc: "Книги и журналы - Легкая атлетика. Плавание. Гимнастика. Тяжелая атлетика. Гребля"} - - {id: 434, cat: Books, desc: "Книги и журналы - Автоспорт. Мотоспорт. Велоспорт"} - - {id: 435, cat: Books, desc: "Книги и журналы - Шахматы. Шашки"} - - {id: 436, cat: Books, desc: "Книги и журналы - Боевые искусства, единоборства"} - - {id: 437, cat: Books, desc: "Книги и журналы - Экстрим"} - - {id: 438, cat: Books, desc: "Книги и журналы - Физкультура, фитнес, бодибилдинг"} - - {id: 439, cat: Books, desc: "Книги и журналы - Спортивная пресса"} - - {id: 440, cat: Books, desc: "Книги и журналы - Гуманитарные науки"} - - {id: 441, cat: Books, desc: "Книги и журналы - Искусствоведение. Культурология"} - - {id: 443, cat: Books, desc: "Книги и журналы - Литературоведение"} - - {id: 444, cat: Books, desc: "Книги и журналы - Лингвистика"} - - {id: 445, cat: Books, desc: "Книги и журналы - Философия"} - - {id: 446, cat: Books, desc: "Книги и журналы - Политология"} - - {id: 447, cat: Books, desc: "Книги и журналы - Социология"} - - {id: 448, cat: Books, desc: "Книги и журналы - Бизнес, менеджмент"} - - {id: 451, cat: Books, desc: "Книги и журналы - Экономика"} - - {id: 311, cat: Books, desc: "Книги и журналы - Военное дело"} - - {id: 312, cat: Books, desc: "Книги и журналы - Милитария"} - - {id: 313, cat: Books, desc: "Книги и журналы - Военная история"} - - {id: 314, cat: Books, desc: "Книги и журналы - История Второй мировой войны"} - - {id: 315, cat: Books, desc: "Книги и журналы - Военная техника"} - - {id: 316, cat: Books, desc: "Книги и журналы - Стрелковое оружие"} - - {id: 317, cat: Books, desc: "Книги и журналы - Учебно-методическая литература"} - - {id: 318, cat: Books, desc: "Книги и журналы - Спецслужбы мира"} - - {id: 39, cat: Books, desc: "Книги и журналы - Художественная литература"} - - {id: 104, cat: Books, desc: "Книги и журналы - Русская литература"} - - {id: 105, cat: Books, desc: "Книги и журналы - Зарубежная литература"} - - {id: 106, cat: Books, desc: "Книги и журналы - Литературные журналы"} - - {id: 107, cat: Books, desc: "Книги и журналы - Художественная литература"} - - {id: 679, cat: Books, desc: "Книги и журналы - Отечественная фантастика / фэнтези / мистика"} - - {id: 102, cat: Books, desc: "Книги и журналы - Компьютерная литература"} - - {id: 103, cat: Books, desc: "Книги и журналы - Компьютерные журналы и приложения к ним"} - - {id: 415, cat: Books, desc: "Книги и журналы - Дисковые приложения к игровым журналам"} - - {id: 409, cat: Books, desc: "Книги и журналы - Программы от Microsoft"} - - {id: 416, cat: Books, desc: "Книги и журналы - Другие программы"} - - {id: 410, cat: Books, desc: "Книги и журналы - Веб-дизайн и программирование"} - - {id: 411, cat: Books, desc: "Книги и журналы - Программирование"} - - {id: 419, cat: Books, desc: "Книги и журналы - Mac OS; Linux, FreeBSD и прочие *NIX"} - - {id: 412, cat: Books, desc: "Книги и журналы - Графика, обработка видео"} - - {id: 414, cat: Books, desc: "Книги и журналы - Инженерные и научные программы"} - - {id: 413, cat: Books, desc: "Книги и журналы - Сети / VoIP"} - - {id: 417, cat: Books, desc: "Книги и журналы - Железо (книги о ПК)"} - - {id: 418, cat: Books, desc: "Книги и журналы - СУБД"} - - {id: 464, cat: Books, desc: "Книги и журналы - Комиксы"} - - {id: 465, cat: Books, desc: "Книги и журналы - Комиксы на русском языке"} - - {id: 466, cat: Books, desc: "Книги и журналы - Комиксы издательства Marvel"} - - {id: 467, cat: Books, desc: "Книги и журналы - Комиксы издательства DC"} - - {id: 468, cat: Books, desc: "Книги и журналы - Комиксы других издательств"} - - {id: 470, cat: Books, desc: "Книги и журналы - Манга (на иностранных языках)"} - - {id: 454, cat: Books, desc: "Книги и журналы - Коллекции книг и библиотеки"} - - {id: 455, cat: Books, desc: "Книги и журналы - Библиотеки (зеркала сетевых библиотек/коллекций)"} - - {id: 456, cat: Books, desc: "Книги и журналы - Тематические коллекции (подборки)"} - - {id: 457, cat: Books, desc: "Книги и журналы - Многопредметные коллекции (подборки)"} - - {id: 458, cat: Books, desc: "Книги и журналы - Мультимедийные и интерактивные издания"} - - {id: 459, cat: Books, desc: "Книги и журналы - Мультимедийные энциклопедии"} - - {id: 460, cat: Books, desc: "Книги и журналы - >Интерактивные обучающие и развивающие материалы"} - - {id: 461, cat: Books, desc: "Книги и журналы - Обучающие издания для детей"} - - {id: 462, cat: Books, desc: "Книги и журналы - Кулинария. Цветоводство. Домоводство"} - - {id: 463, cat: Books, desc: "Книги и журналы - Культура. Искусство. История"} - - {id: 677, cat: Books, desc: "Книги и журналы - Детективы, приключения"} - - {id: 128, cat: Books, desc: "Книги и журналы - Разное"} - - - {id: 774, cat: Other, desc: "Все Графика, Арт, Разное"} - - {id: 775, cat: Other, desc: "Живопись, Графика, Скульптура, Digital Art"} - - {id: 776, cat: Other, desc: "Рисунки, графический дизайн"} - - {id: 778, cat: Other, desc: "Обои, аватары, иконки и прочее"} - - {id: 777, cat: Other, desc: "3D-графика"} - - - {id: 38, cat: PC, desc: "Программное обеспечение"} - - {id: 40, cat: PC, desc: "Программное обеспечение - Операционные системы"} - - {id: 41, cat: PC, desc: "Программное обеспечение - Windows"} - - {id: 42, cat: PC, desc: "Программное обеспечение - Mac"} - - {id: 43, cat: PC, desc: "Программное обеспечение - Linux (Unix)"} - - {id: 112, cat: PC, desc: "Программное обеспечение - Софт"} - - {id: 113, cat: PC, desc: "Программное обеспечение - Сборники программ"} - - {id: 273, cat: PC, desc: "Программное обеспечение - Безопасность"} - - {id: 278, cat: PC, desc: "Программное обеспечение - Информация и диагностика"} - - {id: 275, cat: PC, desc: "Программное обеспечение - Программы для интернет и сетей"} - - {id: 276, cat: PC, desc: "Программное обеспечение - Программы для настройки и оптимизации ОС"} - - {id: 116, cat: PC, desc: "Программное обеспечение - Аудио и видео редакторы и конвертеры"} - - {id: 115, cat: PC, desc: "Программное обеспечение - Графические редакторы"} - - {id: 518, cat: PC, desc: "Программное обеспечение - Каталогизаторы и просмотрщики графики"} - - {id: 117, cat: PC, desc: "Программное обеспечение - Аудио и видео-проигрыватели и каталогизаторы"} - - {id: 282, cat: PC, desc: "Программное обеспечение - Виртуальные студии, секвенсоры"} - - {id: 517, cat: PC, desc: "Программное обеспечение - Словари, переводчики"} - - {id: 667, cat: PC, desc: "Программное обеспечение - Офисное ПО"} - - {id: 668, cat: PC, desc: "Программное обеспечение - Работа с жёстким диском"} - - {id: 669, cat: PC, desc: "Программное обеспечение - Сервисное обслуживание компьютера"} - - {id: 670, cat: PC, desc: "Программное обеспечение - Архиваторы и файловые менеджеры"} - - {id: 114, cat: PC, desc: "Программное обеспечение - Плагины и программы компании Adobe"} - - {id: 274, cat: PC, desc: "Программное обеспечение - Видеокурсы"} - - {id: 675, cat: PC, desc: "Программное обеспечение - Разное (Системные программы для Windows)"} - - {id: 671, cat: PC, desc: "Программное обеспечение - Игры и программы для iOS"} - - {id: 672, cat: PC, desc: "Программное обеспечение - Игры и программы для Android"} - - {id: 673, cat: PC, desc: "Программное обеспечение - Текстовые редакторы"} - - {id: 674, cat: PC, desc: "Программное обеспечение - Работа с носителями информации"} - - {id: 113, cat: PC, desc: "Сборники программ"} - - {id: 273, cat: PC, desc: "Безопасность"} - - {id: 278, cat: PC, desc: "Сериалы - Информация и диагностика"} - - {id: 275, cat: PC, desc: "Программы для интернет и сетей"} - - {id: 276, cat: PC, desc: "Программы для настройки и оптимизации ОС"} - - {id: 116, cat: PC, desc: "Аудио и видео редакторы и конвертеры"} - - {id: 115, cat: PC, desc: "Графические редакторы"} - - {id: 518, cat: PC, desc: "Каталогизаторы и просмотрщики графики"} - - {id: 117, cat: PC, desc: "Аудио и видео-проигрыватели и каталогизаторы"} - - {id: 282, cat: PC, desc: "Виртуальные студии, секвенсоры"} - - {id: 517, cat: PC, desc: "Словари, переводчики"} - - {id: 667, cat: PC, desc: "Офисное ПО"} - - {id: 668, cat: PC, desc: "Работа с жёстким диском"} - - {id: 669, cat: PC, desc: "Сервисное обслуживание компьютера"} - - {id: 670, cat: PC, desc: "Архиваторы и файловые менеджеры"} - - {id: 114, cat: PC, desc: "Плагины и программы компании Adobe"} - - {id: 274, cat: PC, desc: "Видеокурсы"} - - {id: 675, cat: PC, desc: "Разное (Системные программы для Windows)"} - - {id: 671, cat: PC, desc: "Игры и программы для iOS"} - - {id: 672, cat: PC, desc: "Игры и программы для Android"} - - {id: 673, cat: PC, desc: "Текстовые редакторы"} - - {id: 674, cat: PC, desc: "Работа с носителями информации"} - - - {id: 30, cat: Console, desc: "Все Игры"} - - {id: 80, cat: Console, desc: "Игры - Игры для Windows"} - - {id: 81, cat: Console, desc: "Игры - Игры и приложения для Android"} - - {id: 44, cat: Console, desc: "Игры - Игры для PSP"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: sort - type: select - label: Sort requested from site - default: t - options: - nt: created - ts: seeders - sz: size - i: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - -login: - # using cookie method because I could not get the form method to work when I first created the indexer, some hidden security check - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href*="ucp.php?mode=logout"] - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - - paths: - # http://filbi1976.org/search.php?t=0&cs=1&cs_post=1&sc=1&keywords=&cs_where=title&cs_forb=&submit=Hand+Search&fid%5B%5D=0&cs_format=0&cs_year=0&cs_filter=0&sk=nt&sd=d&cs_private=0 - - path: search.php - inputs: - t: 0 - cs: 1 - cs_post: 1 - sc: 1 - keywords: "{{ .Keywords }}" - # title, persona, genre, producer, author, korsar - cs_where: title - cs_forb: "" - submit: "Hand+Search" - $raw: "{{ if .Categories }}{{ range .Categories }}&fid[]={{.}}{{end}}{{ else }}&fid[]=0{{ end }}" - cs_format: 0 - cs_year: 0 - cs_filter: 0 - sk: "{{ .Config.sort }}" - sd: "{{ .Config.type }}" - cs_private: 0 - - rows: - selector: li.row:has(a.topictitle) - - fields: - category: - selector: dt > a[href$=".html"] - attribute: href - filters: - - name: regexp - args: -f(\d+) - title: - selector: a.topictitle - details: - selector: a.topictitle - attribute: href - download: - selector: a[href^="./download/file.php?id="][title="Скачать торрент"] - attribute: href - size: - selector: dt - filters: - - name: regexp - args: "Размер: (.+?)," - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: span.seed - leechers: - selector: span.leech - grabs: - selector: span.complet - date_day: - # Сегодня, 15:10 (today) - # Вчера, 23:28 (yesterday) - selector: dd:last-of-type:contains("Сегодня,"), dd:last-of-type:contains("Вчера,") - remove: a - optional: true - filters: - - name: replace - args: ["»", ""] - - name: replace - args: ["Вчера,", "Today"] - - name: replace - args: ["Сегодня,", "Yesterday"] - - name: fuzzytime - date_time: - # 4 минуты назад (4 minutes ago) - # 11 минут назад (11 minutes ago) - # 4 часа назад (4 hours ago) - # 11 часов назад (11 hours ago) - selector: dd:last-of-type:contains("назад") - remove: a - optional: true - filters: - - name: replace - args: ["»", ""] - - name: replace - args: ["назад", "ago"] - - name: replace - args: ["минуты", "minutes"] - - name: replace - args: ["минут", "minutes"] - - name: replace - args: ["часа", "hours"] - - name: replace - args: ["часов", "hours"] - - name: timeago - date_year: - # 12 янв 2019, 11:26 - # 25 май 2017, 18:41 - selector: dd:last-of-type:not(:contains("назад")):not(:contains("Сегодня,")):not(:contains("Вчера,")) - remove: a - optional: true - filters: - - name: replace - args: ["»", ""] - - name: regexp - args: "(\\d{2} \\D{3} \\d{4}, \\d{2}:\\d{2})" - - name: replace - args: ["янв", "Jan"] - - name: replace - args: ["фев", "Feb"] - - name: replace - args: ["мар", "Mar"] - - name: replace - args: ["апр", "Apr"] - - name: replace - args: ["май", "May"] - - name: replace - args: ["июн", "Jun"] - - name: replace - args: ["июл", "Jul"] - - name: replace - args: ["авг", "Aug"] - - name: replace - args: ["сен", "Sep"] - - name: replace - args: ["окт", "Oct"] - - name: replace - args: ["ноя", "Nov"] - - name: replace - args: ["дек", "Dec"] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "dd MMM yyyy, HH:mm zzz" - date: - text: "{{ if or .Result.date_time .Result.date_year .Result.date_day }}{{ or .Result.date_time .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/krazyzone.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/krazyzone.yml deleted file mode 100644 index 6edf3b7e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/krazyzone.yml +++ /dev/null @@ -1,217 +0,0 @@ ---- -id: krazyzone -name: KrazyZone -description: "KrazyZone is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://krazyzone.net/ -legacylinks: - - http://krazyzone.net/ - -caps: - categorymappings: - - {id: 79, cat: Audio, desc: "Mastermix: Mastermix"} - - {id: 80, cat: Audio, desc: "DMC: DMC"} - - {id: 49, cat: Audio/MP3, desc: "KZN MP3 Weekly: Ex-Vip"} - - {id: 76, cat: Audio, desc: "KZN-Exclusive: Exclusive "} - - {id: 98, cat: Audio/MP3, desc: "Other Mp3 Weeky: Mp3"} - - {id: 77, cat: Audio, desc: "KZN: Out Of VIP"} - - {id: 99, cat: Audio/MP3, desc: "KZ Internal: KZ Internal"} - - {id: 74, cat: Audio, desc: "Discography: Collection"} - - {id: 82, cat: Audio/Lossless, desc: "Flac: Flac-Flac Packs"} - - {id: 101, cat: Audio, desc: "Wav: Wav - Wav Packs"} - - {id: 81, cat: Audio, desc: "Albums : Albums / Singles"} - - {id: 78, cat: Audio, desc: "Dj-Only: Dj-Music"} - - {id: 100, cat: Audio, desc: "Web Music: Flac - MP3"} - - {id: 46, cat: Audio/Video, desc: "Music Video: Music Video"} - - {id: 24, cat: Audio, desc: "Karaoke: All Karaoke"} - - {id: 47, cat: Movies/BluRay, desc: "Movies: Bluray-HD"} - - {id: 52, cat: Movies, desc: "Pre 90s Movies: Pre90s"} - - {id: 28, cat: Movies, desc: "Movies: 2026"} - - {id: 102, cat: Movies/HD, desc: "Movies MP4: Movie MP4"} - - {id: 25, cat: Movies/WEB-DL, desc: "Movies: Web-DL"} - - {id: 105, cat: Movies, desc: "Pre 2000 MP4 Movies: Pre 2000 Mp4"} - - {id: 48, cat: Movies/SD, desc: "Movies: Xvid-Dvd-Webrip"} - - {id: 23, cat: Movies/Other, desc: "Movie: Kids-Anime-Family"} - - {id: 41, cat: Movies/3D, desc: "Remux-3D: Remux-3D"} - - {id: 86, cat: Movies/HD, desc: "1440P QHD: 1440P QHD"} - - {id: 9, cat: Movies/DVD, desc: "Movies: Ntsc Dvdr-ISO"} - - {id: 27, cat: Movies/UHD, desc: "Movies: 4K"} - - {id: 22, cat: Movies/Foreign, desc: "Movies: Foreign"} - - {id: 53, cat: Movies, desc: "Movie Packs: Movie Pks"} - - {id: 54, cat: Movies, desc: "Movie Pack: Pre-90s"} - - {id: 6, cat: Movies/Other, desc: "Kids Movie Pk: Movie Pks"} - - {id: 7, cat: TV, desc: "TV: Episodes"} - - {id: 8, cat: TV/Anime, desc: "TV: Kids - TV"} - - {id: 75, cat: TV/Documentary, desc: "TV: Documentary"} - - {id: 26, cat: TV/Sport, desc: "TV: Sports"} - - {id: 42, cat: Movies, desc: "TV Packs: Full Seasons"} - - {id: 50, cat: TV/Anime, desc: "TV Packs: Kids- Anime-Packs"} - - {id: 83, cat: Movies, desc: "TV Packs: Pre90s"} - - {id: 84, cat: Audio/Audiobook, desc: "Audio Books: A-Books"} - - {id: 85, cat: Books, desc: "Ebook: Ebook-Mag-Comic"} - - {id: 87, cat: PC/0day, desc: "Apps: Window-Linux-Mac"} - - {id: 88, cat: TV/Anime, desc: "Anime: All Anime"} - - {id: 89, cat: PC/Games, desc: "Games: Pc - Iso"} - - {id: 90, cat: PC/Games, desc: "Games: Misc"} - - {id: 91, cat: Other, desc: "Wallpapers: Wallpapers"} - - {id: 92, cat: Other, desc: "Xmas Albums: Albums"} - - {id: 93, cat: Movies/Other, desc: "Xmas Movies : Packs"} - - {id: 94, cat: Audio/Other, desc: "Xmas Music Pack: Packs"} - - {id: 95, cat: Other, desc: "Misc: Misc"} - - {id: 106, cat: Audio, desc: "Sals Top Torren: Never Forgotten"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting 0 results then access the site with your browser and mark as read all PMs." - - name: info_activity - type: info - label: Account Inactivity - default: "Be active here, you must login at least once a month. Also, not downloading anything is considered being inactive (except if you're an uploader or VIP). Accounts that are inactive for 30 days are deleted unless you have told staff in advance." - -login: - path: account-login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.myFrame:contains("Access Denied") - test: - path: index.php - selector: a[href="account-logout.php"] - -search: - paths: - # https://krazyzone.net/torrents-search.php?incldead=1&freeleech=0&inclrosub=0&sort=id&order=desc - - path: torrents-search.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 notfree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 all, 1 bulgarian, 2 english, 3 english and bulgarian - inclrosub: 0 - # 0 both, 1 local, 2 external - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - rows: - selector: table.ttable_headinner > tbody > tr:has(a[href^="download.php?id="]) - filters: - - name: andmatch - - fields: - category_optional: - selector: a[href^="torrents.php?cat="] - attribute: href - optional: true - filters: - - name: querystring - args: cat - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}95{{ end }}" - title: - selector: a[href^="torrents-details.php?id="] - attribute: title - filters: - - name: re_replace - args: ["[\\[|\\]]", ""] - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: a[href^="torrents-details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - genre: - selector: div.t-tags - filters: - - name: re_replace - args: ["(?i)(Chat Show)", "Chat_Show"] - - name: re_replace - args: ["(?i)(Game Show)", "Game_Show"] - - name: re_replace - args: ["(?i)(Panel Show)", "Panel_Show"] - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(Talk Show)", "Talk_Show"] - - name: validate - args: "Action, Adventure, Animation, Biography, Chat_Show, Comedy, Crime, Documentary, Drama, Family, Fantasy, Game_Show, History, Horror, Kids, Medical, Music, Mystery, Nature, News, Panel_Show, Reality, Romance, Science_Fiction, Science-Fiction, SciFi, Soap, Supernatural, Talk_Show, Thriller, War, Western" - description: - case: - img[src="images/ro-eng-flag.png"]: "Verified: {{ .Result.genre }}" - "*": "Unverified: {{ .Result.genre }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: td:nth-child(4) - date: - # 24.Jul
2022
07:47 - selector: td:nth-child(5) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "d.MMMyyyyHH:mm" - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src="images/free.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 24 hours (as seconds = 24 x 60 x 60) - text: 86400 -# TorrentTrader diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufei.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufei.yml deleted file mode 100644 index 6e45ea86..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufei.yml +++ /dev/null @@ -1,211 +0,0 @@ ---- -id: kufei -name: Kufei (库非) -description: "Kufei (库非) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://kufei.org/ - -caps: - categorymappings: - - {id: 410, cat: Console, desc: "Games/游戏"} - - {id: 411, cat: Books, desc: "E-Books/电子书"} - - {id: 408, cat: Audio, desc: "Music/音乐"} - - {id: 412, cat: PC, desc: "Software/软件"} - - {id: 415, cat: TV, desc: "Drama/戏剧"} - - {id: 416, cat: Audio/Audiobook, desc: "Audio Books/有声读物"} - - {id: 409, cat: Other, desc: "Others/其他"} - - {id: 413, cat: Other, desc: "Education/教育"} - - {id: 414, cat: Audio/Video, desc: "Concert/演唱会"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐、视频"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 405, cat: TV/Anime, desc: "Animations/动画、动漫"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the Kufei Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 30 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.10.1 2026-02-17 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufirc.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufirc.yml deleted file mode 100644 index 2c0bfab3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufirc.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: kufirc -name: Kufirc -description: "Kufirc is a HUNGARIAN Private tracker for 3X" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://kufirc.com/ - -caps: - categorymappings: - - {id: 30, cat: XXX, desc: "Amateur"} - - {id: 29, cat: XXX, desc: "Anal"} - - {id: 5, cat: XXX, desc: "Animal"} - - {id: 27, cat: XXX, desc: "Asian"} - - {id: 26, cat: XXX, desc: "BBW"} - - {id: 44, cat: XXX, desc: "BDSM"} - - {id: 25, cat: XXX, desc: "Big Ass"} - - {id: 24, cat: XXX, desc: "Big Tits"} - - {id: 34, cat: XXX, desc: "Black"} - - {id: 35, cat: XXX, desc: "Busty"} - - {id: 23, cat: XXX, desc: "Classic"} - - {id: 33, cat: XXX, desc: "Creampie"} - - {id: 22, cat: XXX, desc: "Cumshot"} - - {id: 36, cat: XXX, desc: "Feature"} - - {id: 21, cat: XXX, desc: "Fetish"} - - {id: 31, cat: XXX, desc: "Film/Cam/Hun"} - - {id: 46, cat: XXX/XviD, desc: "Film/Xvid/hun"} - - {id: 37, cat: XXX, desc: "Foreign"} - - {id: 19, cat: XXX, desc: "Gay / Bi"} - - {id: 18, cat: XXX, desc: "Hardcore"} - - {id: 32, cat: XXX, desc: "HD porn"} - - {id: 28, cat: XXX, desc: "Hentai / 3D"} - - {id: 17, cat: XXX, desc: "Homemade"} - - {id: 38, cat: XXX, desc: "Interracial"} - - {id: 11, cat: XXX, desc: "Kép"} - - {id: 48, cat: XXX, desc: "Latina"} - - {id: 16, cat: XXX, desc: "Lesbian"} - - {id: 56, cat: Books/Mags, desc: "Magazines"} - - {id: 6, cat: XXX, desc: "Magyar"} - - {id: 15, cat: XXX, desc: "Masturbation"} - - {id: 14, cat: XXX, desc: "Mature"} - - {id: 13, cat: XXX/Pack, desc: "Megapack"} - - {id: 55, cat: XXX, desc: "Milf"} - - {id: 49, cat: XXX, desc: "Natural Tits"} - - {id: 39, cat: XXX, desc: "Old + Young"} - - {id: 12, cat: XXX, desc: "Oral"} - - {id: 20, cat: XXX, desc: "Orgia / Gang Bang"} - - {id: 42, cat: XXX/Other, desc: "Other"} - - {id: 50, cat: XXX, desc: "Parody"} - - {id: 41, cat: XXX, desc: "Paysite"} - - {id: 40, cat: XXX, desc: "Piss"} - - {id: 43, cat: XXX, desc: "Porn Music Videos"} - - {id: 54, cat: XXX, desc: "Pov"} - - {id: 45, cat: XXX, desc: "Pregnant / Preggo"} - - {id: 57, cat: XXX, desc: "Scat/Puke"} - - {id: 10, cat: XXX, desc: "Shemale / TS"} - - {id: 51, cat: XXX, desc: "Siterip"} - - {id: 52, cat: XXX, desc: "Softcore"} - - {id: 53, cat: XXX, desc: "Squirt"} - - {id: 9, cat: XXX, desc: "Straight"} - - {id: 8, cat: XXX, desc: "Tini"} - - {id: 47, cat: XXX, desc: "VR"} - - {id: 7, cat: PC/Games, desc: "XXX játék / program"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the Kufirc2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek oldalanként: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cinfo: "1920|1080|24|-60" - iplocked: 0 - keeploggedin: 1 - error: - - selector: div.error - test: - path: / - selector: "#nav_userinfo" - -search: - paths: - # https://kufirc.com/torrents.php?filter_cat[30]=1&filter_cat[29]=1&order_by=time&order_way=desc&searchtext=&action=advanced&title=720p+2022&sizeall=&sizetype=&sizerange=&filelist=&taglist=&autocomplete_toggle= - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "" - title: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sizeall: "" - sizetype: "" - sizerange: "" - filelist: "" - taglist: "" - autocomplete_toggle: "" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "(\\d+)]=1" - title: - selector: a[href^="/torrents.php?id="] - details: - selector: a[href^="/torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufirc2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufirc2fa.yml deleted file mode 100644 index 0879027e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kufirc2fa.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: kufirc2fa -name: Kufirc2FA -description: "Kufirc is a HUNGARIAN Private tracker for 3X. This indexer uses cookie login for 2FA." -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://kufirc.com/ - -caps: - categorymappings: - - {id: 30, cat: XXX, desc: "Amateur"} - - {id: 29, cat: XXX, desc: "Anal"} - - {id: 5, cat: XXX, desc: "Animal"} - - {id: 27, cat: XXX, desc: "Asian"} - - {id: 26, cat: XXX, desc: "BBW"} - - {id: 44, cat: XXX, desc: "BDSM"} - - {id: 25, cat: XXX, desc: "Big Ass"} - - {id: 24, cat: XXX, desc: "Big Tits"} - - {id: 34, cat: XXX, desc: "Black"} - - {id: 35, cat: XXX, desc: "Busty"} - - {id: 23, cat: XXX, desc: "Classic"} - - {id: 33, cat: XXX, desc: "Creampie"} - - {id: 22, cat: XXX, desc: "Cumshot"} - - {id: 36, cat: XXX, desc: "Feature"} - - {id: 21, cat: XXX, desc: "Fetish"} - - {id: 31, cat: XXX, desc: "Film/Cam/Hun"} - - {id: 46, cat: XXX/XviD, desc: "Film/Xvid/hun"} - - {id: 37, cat: XXX, desc: "Foreign"} - - {id: 19, cat: XXX, desc: "Gay / Bi"} - - {id: 18, cat: XXX, desc: "Hardcore"} - - {id: 32, cat: XXX, desc: "HD porn"} - - {id: 28, cat: XXX, desc: "Hentai / 3D"} - - {id: 17, cat: XXX, desc: "Homemade"} - - {id: 38, cat: XXX, desc: "Interracial"} - - {id: 11, cat: XXX, desc: "Kép"} - - {id: 48, cat: XXX, desc: "Latina"} - - {id: 16, cat: XXX, desc: "Lesbian"} - - {id: 56, cat: Books/Mags, desc: "Magazines"} - - {id: 6, cat: XXX, desc: "Magyar"} - - {id: 15, cat: XXX, desc: "Masturbation"} - - {id: 14, cat: XXX, desc: "Mature"} - - {id: 13, cat: XXX/Pack, desc: "Megapack"} - - {id: 55, cat: XXX, desc: "Milf"} - - {id: 49, cat: XXX, desc: "Natural Tits"} - - {id: 39, cat: XXX, desc: "Old + Young"} - - {id: 12, cat: XXX, desc: "Oral"} - - {id: 20, cat: XXX, desc: "Orgia / Gang Bang"} - - {id: 42, cat: XXX/Other, desc: "Other"} - - {id: 50, cat: XXX, desc: "Parody"} - - {id: 41, cat: XXX, desc: "Paysite"} - - {id: 40, cat: XXX, desc: "Piss"} - - {id: 43, cat: XXX, desc: "Porn Music Videos"} - - {id: 54, cat: XXX, desc: "Pov"} - - {id: 45, cat: XXX, desc: "Pregnant / Preggo"} - - {id: 57, cat: XXX, desc: "Scat/Puke"} - - {id: 10, cat: XXX, desc: "Shemale / TS"} - - {id: 51, cat: XXX, desc: "Siterip"} - - {id: 52, cat: XXX, desc: "Softcore"} - - {id: 53, cat: XXX, desc: "Squirt"} - - {id: 9, cat: XXX, desc: "Straight"} - - {id: 8, cat: XXX, desc: "Tini"} - - {id: 47, cat: XXX, desc: "VR"} - - {id: 7, cat: PC/Games, desc: "XXX játék / program"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek oldalanként: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: "#nav_userinfo" - -search: - paths: - # https://kufirc.com/torrents.php?filter_cat[30]=1&filter_cat[29]=1&order_by=time&order_way=desc&searchtext=&action=advanced&title=720p+2022&sizeall=&sizetype=&sizerange=&filelist=&taglist=&autocomplete_toggle= - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "" - title: "{{ .Keywords }}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sizeall: "" - sizetype: "" - sizerange: "" - filelist: "" - taglist: "" - autocomplete_toggle: "" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "(\\d+)]=1" - title: - selector: a[href^="/torrents.php?id="] - details: - selector: a[href^="/torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) > span - attribute: title - # auto adjusted by site account profile - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - "img[alt=\"Freeleech\"]": 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 -# Luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kunlun.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kunlun.yml deleted file mode 100644 index 36c9355f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/kunlun.yml +++ /dev/null @@ -1,178 +0,0 @@ ---- -id: kunlun -name: Kunlun (昆仑) -description: "Kunlun (昆仑) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.yhpp.cc/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫、动画"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐视频"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 408, cat: Audio/Lossless, desc: "Music/音乐"} - - {id: 409, cat: Other, desc: "Others/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.13 2025-12-28 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lacale-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lacale-api.yml deleted file mode 100644 index 95ddf14d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lacale-api.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: lacale-api -name: La Cale (API) -description: "La Cale is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -requestDelay: 5.0 -links: - - https://la-cale.space/ - -caps: - # use https://la-cale.space/api/external/meta?apikey=YOUR-APIKEY for cat mappings, slug is id, name is desc - categorymappings: - # Autres - - {id: autres, cat: Other, desc: "Autres"} - # E-books - - {id: e-books, cat: Books/EBook, desc: "E-books"} - - {id: bd, cat: Books/Comics, desc: "BD"} - - {id: comics, cat: Books/Comics, desc: "Comics"} - - {id: divers, cat: Books/Other, desc: "Divers"} - - {id: livres, cat: Books/EBook, desc: "Livres"} - - {id: mangas, cat: Books/Comics, desc: "Mangas"} - - {id: presse, cat: Books/Mags, desc: "Presse"} - # Audio - - {id: audio, cat: Audio, desc: "Audio"} - - {id: audio-divers, cat: Audio/Other, desc: "Audio divers"} - - {id: audiobooks, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: music, cat: Audio, desc: "Musique"} - - {id: flac, cat: Audio/Lossless, desc: "FLAC"} - - {id: m4a, cat: Audio, desc: "M4A"} - - {id: mp3, cat: Audio/MP3, desc: "MP3"} - - {id: podcast, cat: Audio/Other, desc: "Podcast"} - # Jeux - - {id: games, cat: PC/Games, desc: "Jeux"} - - {id: nintendo, cat: Console/NDS, desc: "Nintendo"} - - {id: pc, cat: PC/Games, desc: "PC"} - - {id: playstation, cat: Console/PS4, desc: "Playstation"} - - {id: sega, cat: Console/Other, desc: "Sega"} - - {id: t-l-phones, cat: PC/Mobile-Other, desc: "Téléphones"} - - {id: vr, cat: PC/Games, desc: "VR"} - - {id: xbox, cat: Console/XBox, desc: "Xbox"} - # Applications - - {id: applications, cat: PC, desc: "Applications"} - - {id: software, cat: PC, desc: "Logiciels"} - - {id: linux, cat: PC, desc: "Linux"} - - {id: systemes, cat: PC, desc: "Systèmes"} - # Vidéo - - {id: video, cat: Movies, desc: "Vidéo"} - - {id: video, cat: TV, desc: "Vidéo"} - - {id: video, cat: TV/Anime, desc: "Anime"} - - {id: films, cat: Movies, desc: "Films"} - - {id: spectacles-concerts, cat: TV/Other, desc: "Spectacles & Concerts"} - - {id: sports, cat: TV/Sport, desc: "Sports"} - - {id: series, cat: TV, desc: "Séries TV"} - # XXX - - {id: xxx, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, tmdbid] - movie-search: [q, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: API key - - name: info_apikey - type: info - label: About your API key - default: "Find/Generate your API key on your La Cale API keys." - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -login: - path: api/external - method: get - inputs: - apikey: "{{ .Config.apikey }}" - error: - - selector: ":root:contains(\"API key invalide\")" - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: ":root:contains(\"API key manquant\")" - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - -search: - paths: - # docs at https://la-cale.space/faq/api - - path: api/external - response: - type: json - - inputs: - apikey: "{{ .Config.apikey }}" - $raw: "{{ range .Categories }}cat={{.}}&{{end}}" - q: "{{ if .Keywords }}{{ .Keywords }}{{ else }}*{{ end }}" - tmdbId: "{{ .Query.TMDBID }}" - - rows: - selector: $ - - fields: - categorydesc: - selector: category - title_phase1: - selector: title - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: link - download: - selector: downloadLink - infohash: - selector: infoHash - size: - selector: size - date: - # "pubDate": "2026-01-16T16:32:19.243Z" is returned by Newtonsoft.Json.Linq as 16/01/2026 16:32:19 - selector: pubDate - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json API v1.3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lajidui.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lajidui.yml deleted file mode 100644 index 226d0d61..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lajidui.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: lajidui -name: lajidui -description: "lajidui is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.lajidui.top/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentary/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐视频"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 408, cat: Audio, desc: "Music/音频"} - - {id: 409, cat: Other/Misc, desc: "Misc/其他"} - - {id: 410, cat: TV/Anime, desc: "Cartoon/少儿动画"} - - {id: 411, cat: Books, desc: "EBook/电子书"} - - {id: 412, cat: TV, desc: "Playlet/短剧"} - - {id: 413, cat: PC/Games, desc: "Games/游戏"} - - {id: 414, cat: PC/0day, desc: "APP/软件"} - - {id: 415, cat: Other, desc: "Education/教育视频"} - - {id: 416, cat: Audio/Audiobook, desc: "Audiobook/有声书"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.9 2025-10-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lastdigitalunderground.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lastdigitalunderground.yml deleted file mode 100644 index cf69b6ef..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lastdigitalunderground.yml +++ /dev/null @@ -1,243 +0,0 @@ ---- -id: lastdigitalunderground -name: Last Digital Underground -description: "Last Digital Underground (LDU) is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://theldu.to/ -legacylinks: - - https://theldu.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Films: General"} - - {id: 21, cat: Movies/3D, desc: "Films: 3D"} - - {id: 33, cat: Movies/Other, desc: "Films: [TMDB UnListed]"} - - {id: 25, cat: Movies/Other, desc: "Films: Musical"} - - {id: 18, cat: Movies/Other, desc: "Films: Silent"} - - {id: 24, cat: Movies/Other, desc: "Films: Holiday"} - - {id: 19, cat: Movies/Other, desc: "Films: Shorts"} - - {id: 42, cat: Movies/Other, desc: "Films: Shorts [TMDB UnListed]"} - - {id: 17, cat: TV/Documentary, desc: "Films: Documentary"} - - {id: 12, cat: Movies/Other, desc: "Films: FanEdits/Restorations"} - - {id: 27, cat: Movies/Foreign, desc: "Films SP1: Dubs/Cams/Vid Issues"} - - {id: 22, cat: Movies/Foreign, desc: "Films SP2: No English"} - - {id: 28, cat: Movies/Other, desc: "Films SP3: Other"} - - {id: 8, cat: Movies/Other, desc: "Anime: Films"} - - {id: 9, cat: TV/Anime, desc: "Anime: Series/OVAs"} - - {id: 2, cat: TV, desc: "Series/TV: Complete Seasons"} - - {id: 41, cat: TV, desc: "Series/TV: Single Episodes"} - - {id: 31, cat: TV/Foreign, desc: "Series/TV SP1: video issues or Dub"} - - {id: 29, cat: TV/Foreign, desc: "Series/TV SP2: No English"} - - {id: 32, cat: TV/Other, desc: "Series/TV SP3: Other"} - - {id: 38, cat: TV/Other, desc: "Series/TV [TMDB unlisted]"} - - {id: 40, cat: TV/Documentary, desc: "Series: Documentary"} - - {id: 46, cat: Other, desc: "Collections [Various Media]"} - - {id: 20, cat: TV/Other, desc: "Culture: Stand-up"} - - {id: 7, cat: Books/EBook, desc: "Culture: Ebooks"} - - {id: 3, cat: Audio, desc: "Culture: Music"} - - {id: 16, cat: Audio/Video, desc: "Culture: Music Videos"} - - {id: 34, cat: Audio/Audiobook, desc: "Culture: Audio Books"} - - {id: 35, cat: TV/Sport, desc: "Culture: Sports"} - - {id: 37, cat: TV/Other, desc: "Culture: Firearms and Freedom"} - - {id: 36, cat: TV/Other, desc: "Culture: Roms"} - - {id: 6, cat: XXX, desc: "Mature: NSFW [TMDB Listed]"} - - {id: 45, cat: XXX, desc: "Mature: NSFW No ENG Subs"} - - {id: 30, cat: XXX, desc: "Mature: NSFW [UnListed]"} - - {id: 52, cat: XXX, desc: "Mature: NSFW LGBTQ [UnListed]"} - - {id: 15, cat: XXX/Pack, desc: "Mature: NSFW MegaPacks"} - - {id: 10, cat: XXX, desc: "Mature: Hentai"} - - {id: 14, cat: XXX/Other, desc: "Mature: Creators Corner"} - - {id: 39, cat: XXX/Other, desc: "Mature: NSFL [Listed]"} - - {id: 11, cat: XXX/Other, desc: "Mature: NSFL [Unlisted]"} - - {id: 44, cat: XXX/Other, desc: "Mature: Audio"} - - {id: 47, cat: PC/0day, desc: "Software: PC"} - - {id: 48, cat: PC/Mac, desc: "Software: Mac"} - - {id: 49, cat: PC, desc: "Software: Linux/Unix"} - - {id: 50, cat: Console, desc: "Software: Console/ROMS"} - - {id: 51, cat: XXX/Other, desc: "Software: Adult Games Mature"} - - {id: 43, cat: Other, desc: "Internal Testing"} - - {id: 23, cat: Other, desc: "Other_Or_Not_Sure"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Last Digital Underground account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in once every 60 days. You must download at least 1 item within your first month." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.0.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lastfiles.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lastfiles.yml deleted file mode 100644 index e55ff2fd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lastfiles.yml +++ /dev/null @@ -1,319 +0,0 @@ ---- -id: lastfiles -name: LastFiles -description: "LastFiles (LF) is a ROMANIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ro-RO -type: private -encoding: UTF-8 -followredirect: true -requestDelay: 2 -links: - - https://lastfiles.ro/ - - https://www.last-torrents.org/ -legacylinks: - - http://last-torrents.org/ - - https://www.lastfiles.ro/ - - http://www.lastfiles.ro/ - - http://www.last-torrents.org/ - - https://last-torrents.org/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Movies: Anime", default: true} - - {id: 2, cat: TV/Anime, desc: "Movies: Anime-Ro", default: true} - - {id: 5, cat: Movies/BluRay, desc: "Movies: BluRay", default: true} - - {id: 6, cat: Movies/BluRay, desc: "Movies: BluRay-Ro", default: true} - - {id: 9, cat: Movies/DVD, desc: "Movies: DVD", default: true} - - {id: 10, cat: Movies/DVD, desc: "Movies: DVD-Ro", default: true} - - {id: 11, cat: Movies/HD, desc: "Movies: HD", default: true} - - {id: 12, cat: Movies/HD, desc: "Movies: HD-Ro", default: true} - - {id: 16, cat: Movies, desc: "Movies: Old", default: true} - - {id: 17, cat: Movies, desc: "Movies: Old-Ro", default: true} - - {id: 18, cat: Movies, desc: "Movies: Pack", default: true} - - {id: 19, cat: Movies, desc: "Movies: Pack-Ro", default: true} - - {id: 24, cat: Movies/SD, desc: "Movies: XVID", default: true} - - {id: 25, cat: Movies/SD, desc: "Movies: XVID-Ro", default: true} - - {id: 31, cat: Movies/UHD, desc: "Movies: 4K", default: true} - - {id: 32, cat: Movies/UHD, desc: "Movies: 4KRo", default: true} - - {id: 54, cat: Movies/WEB-DL, desc: "Movies: WEB-DL", default: true} - - {id: 55, cat: Movies/WEB-DL, desc: "Movies: WEB-DL Ro", default: true} - - {id: 81, cat: Movies/HD, desc: "Movies: x265-Ro", default: true} - - {id: 82, cat: Movies/HD, desc: "Movies: x265", default: true} - - {id: 13, cat: TV/HD, desc: "HDTV Episodes", default: true} - - {id: 14, cat: TV/HD, desc: "HDTV Episodes-Ro", default: true} - - {id: 20, cat: TV, desc: "TV Episodes", default: true} - - {id: 21, cat: TV, desc: "TV Episodes-Ro", default: true} - - {id: 22, cat: Other, desc: "RoContent", default: true} - - {id: 26, cat: PC/0day, desc: "Software", default: true} - - {id: 28, cat: Audio, desc: "Music", default: true} - - {id: 30, cat: PC/Games, desc: "Games: PC-ISO", default: true} - - {id: 33, cat: PC/Games, desc: "Games: Packs", default: true} - - {id: 42, cat: TV/Sport, desc: "Sport", default: true} - - {id: 43, cat: Books, desc: "Documents", default: true} - - {id: 44, cat: Other, desc: "Images", default: true} - - {id: 49, cat: Other, desc: "Diverse", default: true} - - {id: 56, cat: TV/Documentary, desc: "Documentary", default: true} - - {id: 57, cat: TV/Documentary, desc: "Documentary-Ro", default: true} - - {id: 27, cat: XXX/x264, desc: "Movies: XXX", default: false} - - {id: 34, cat: XXX/Pack, desc: "XXX Pack", default: false} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort2 - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: login - method: form - form: form[action$="/login"] - inputs: - name: "{{ .Config.username }}" - password: "{{ .Config.password }}" - remember: on - selectorinputs: - _token: - selector: input[name="_token"] - attribute: value - error: - - selector: div.alert-danger:contains("Invalid credentials") - test: - path: / - selector: a[href$="/logout"] - -search: - paths: - # https://lastfiles.ro/torrents?keyword=tt8337290&category=32&genre=&torrent_status=free - - path: torrents - categories: [1, 2, 5, 6, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 24, 25, 26, 28, 30, 31, 32, 33, 42, 43, 44, 49, 54, 55, 56, 57, 81, 82] - inputs: - $raw: "{{ range .Categories }}categories[]={{.}}&{{end}}" - # does not support multi categories - - path: torrents/adult - categories: [27, 34] - - inputs: - keyword: "{{ .Keywords }}" - # active, dead, free, double, seedbox - torrent_status: "{{ if .Config.freeleech }}free{{ else }}active{{ end }}" - sort: "{{ .Config.sort2 }}" - direction: "{{ .Config.type }}" - - keywordsfilters: - # replace spaces between words with percent wildcard - - name: re_replace - args: ["\\W+", "%"] - - rows: - selector: div.g-0:has(a[href*="/download/"]) - - fields: - category: - selector: img[src*="images/categories"] - attribute: src - case: - # not all categories are available at the moment, so some are deduced and unconfirmed - img[src$="/anime.png"]: 1 - img[src$="/anime-ro.png"]: 2 - img[src$="/bluray.png"]: 5 - img[src$="/bluray-ro.png"]: 6 - img[src$="/dvd.png"]: 9 - img[src$="/dvd-ro.png"]: 10 - img[src$="/hd.png"]: 11 - img[src$="/hd-ro.png"]: 12 - img[src$="/hdtve.png"]: 13 - img[src$="/hdtve-ro.png"]: 14 - img[src$="/oldies.png"]: 16 - img[src$="/oldies-ro.png"]: 17 - img[src$="/pack.png"]: 18 - img[src$="/pack-ro.png"]: 19 - img[src$="/tvepisode.png"]: 20 - img[src$="/tvepisode-ro.png"]: 21 - img[src$="/rocontent.png"]: 22 - img[src$="/xvid.png"]: 24 - img[src$="/xvid-ro.png"]: 25 - img[src$="/soft.png"]: 26 - img[src$="/xxx.jpeg"]: 27 - img[src$="/music.png"]: 28 - img[src$="/pciso.png"]: 30 - img[src$="/4k.png"]: 31 - img[src$="/4kRO.png"]: 32 - img[src$="/gpack.png"]: 33 - img[src$="/xxxpack.jpeg"]: 34 - img[src$="/sport.png"]: 42 - img[src$="/docs.png"]: 43 - img[src$="/images.png"]: 44 - img[src$="/misc.png"]: 49 - img[src$="/web-DL.png"]: 54 - img[src$="/web-DLRO.png"]: 55 - img[src$="/doc.png"]: 56 - img[src$="/doc-ro.png"]: 57 - img[src$="/x265ro.png"]: 81 - img[src$="/x265.png"]: 82 - title: - selector: a[href*="/torrents/"] - details: - selector: a[href*="/torrents/"] - attribute: href - poster: - selector: a[href*="/torrents/"] - attribute: data-bs-title - filters: - - name: regexp - args: "src='(.+?)'" - genre: - selector: div.gap-2 - remove: style - filters: - - name: replace - args: ["\n", ""] - - name: replace - args: [" & ", "_&_"] - - name: replace - args: ["Science Fiction", "Science_Fiction"] - - name: replace - args: ["TV Movie", "TV_Movie"] - - name: replace - args: ["Various Artists", "Various_Artists"] - - name: replace - args: ["Packs Android", "Packs_Android"] - - name: replace - args: ["Hard Rock", "Hard_Rock"] - - name: replace - args: ["Romanian Standup Comedy", "Romanian_Standup_Comedy"] - - name: replace - args: ["Windows Apps", "Windows_Apps"] - - name: replace - args: ["Rock Pop Soul soft rock", "Rock_Pop_Soul_soft_rock"] - - name: replace - args: ["Love songs", "Love_songs"] - - name: replace - args: ["Dance-pop Synth-pop", "Dance-pop_Synth-pop"] - - name: replace - args: ["R&B Soul", "R&B_Soul"] - - name: replace - args: ["Heavy Metal", "Heavy_Metal"] - - name: replace - args: ["Alternative Rock Electronic", "Alternative_Rock_Electronic"] - - name: replace - args: ["New Wave", "New_Wave"] - - name: replace - args: ["Alternative Rock", "Alternative_Rock"] - - name: replace - args: ["Deep House Mix", "Deep_House_Mix"] - - name: replace - args: ["Folk metal", "Folk_metal"] - - name: replace - args: ["Ro eBook", "Ro_eBook"] - - name: replace - args: ["Pop rock", "Pop_rock"] - - name: replace - args: ["Indie Folk", "Indie_Folk"] - - name: replace - args: ["Folk Rock", "Folk_Rock"] - - name: replace - args: ["World Music", "World_Music"] - - name: replace - args: ["Black Metal", "Black_Metal"] - - name: replace - args: ["Blues Rock", "Blues_Rock"] - - name: replace - args: ["Jazz Rock", "Jazz_Rock"] - - name: replace - args: ["Indie Pop", "Indie_Pop"] - - name: replace - args: ["Indie Rock", "Indie_Rock"] - - name: replace - args: ["XXX Images", "XXX_Images"] - - name: replace - args: ["Biblia Sacra", "Biblia_Sacra"] - - name: replace - args: ["Video Downloader", "Video_Downloader"] - - name: replace - args: ["XXX ADULT +18", "XXX_ADULT_+18"] - - name: replace - args: ["Fotbal | UEFA EURO 2025 U21", "Fotbal_|_UEFA_EURO_2025_U21"] - - name: replace - args: ["Dezvoltare Personala", "Dezvoltare_Personala"] - - name: replace - args: ["Action Drama War History Adventure", "Action_Drama_War_History_Adventure"] - - name: replace - args: ["Bossa Nova", "Bossa_Nova"] - - name: replace - args: ["Funk Soul", "Funk_Soul"] - - name: replace - args: ["Formula 1", "Formula_1"] - - name: replace - args: ["Literatura Universala", "Literatura_Universala"] - - name: replace - args: ["Romane inspirate de Holocaust", "Romane_inspirate_de_Holocaust"] - - name: replace - args: ["Politica si stiinte politice", "Politica_si_stiinte_politice"] - - name: replace - args: ["Comentarii si opinii", "Comentarii_si_opinii"] - - name: replace - args: ["Studii și eseuri", "Studii_ș_i_eseuri"] - description: - text: "{{ .Result.genre }}" - download: - selector: a[href*="/download/"] - attribute: href - date: - selector: div.d-none:contains(",") - filters: - - name: dateparse - args: "MMM dd, yyyy" - size: - selector: div.d-none:contains(" GB"), div.d-none:contains(" MB"), div.d-none:contains(" KB"), div.d-none:contains(" TB") - grabs: - selector: div.text-success - optional: true # torrents/adult does not have grabs - seeders: - selector: span.text-success - leechers: - selector: span.text-danger - downloadvolumefactor: - case: - div.free-btn: 0 - "*": 1 - uploadvolumefactor: - case: - div.double-btn: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# Last Files V2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lat-team-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lat-team-api.yml deleted file mode 100644 index 55ff993f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lat-team-api.yml +++ /dev/null @@ -1,214 +0,0 @@ ---- -id: lat-team-api -name: Lat-Team (API) -description: "Lat-Team is a SPANISH Private Torrent Tracker for MOVIES / TV" -language: es-MX -type: private -encoding: UTF-8 -links: - - https://lat-team.com/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Peliculas"} - - {id: 2, cat: TV, desc: "TV Series"} - - {id: 5, cat: TV/Anime, desc: "Anime"} - - {id: 20, cat: TV/Foreign, desc: "Doramas & Turcas"} - - {id: 8, cat: TV/Foreign, desc: "Telenovelas"} - - {id: 3, cat: Audio, desc: "Musica"} - - {id: 9, cat: Audio/Video, desc: "Conciertos"} - - {id: 16, cat: TV/Sport, desc: "Eventos Deportivos"} - - {id: 22, cat: TV/Other, desc: "Playlist_Collection"} - - {id: 17, cat: PC, desc: "Software & O.S."} - - {id: 12, cat: XXX, desc: "XXX"} - - {id: 18, cat: Books/EBook, desc: "E-Books"} - - {id: 11, cat: Audio/Audiobook, desc: "Audiolibros"} - - {id: 4, cat: Console, desc: "Juegos"} - - {id: 24, cat: Other, desc: "Cursos"} - - {id: 29, cat: Books/Mags, desc: "Revistas & Periódicos"} - - {id: 30, cat: Books/Comics, desc: "Comics & Manga"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Lat-Team account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in once every 90 days or seed at least 5 torrents to avoid having your account deactivated." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title_phase1: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - title_vose: - selector: name:contains(VOSE) - optional: true - title: - text: "{{ .Result.title_phase1 }}{{ if .Result.title_vose }} ENGLiSH{{ else }} SPANiSH{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.25 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.25 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/learnflakes.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/learnflakes.yml deleted file mode 100644 index 09708cc1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/learnflakes.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: learnflakes -name: LearnFlakes -description: "LearnFlakes is a Private Torrent Tracker for CERTIFICATE / TRAINING E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://learnflakes.net/ - -caps: - categorymappings: - - {id: 1, cat: Books, desc: "LearnFlakes"} - - {id: 14, cat: Books, desc: "EMC"} - - {id: 16, cat: Books, desc: "SANS"} - - {id: 17, cat: Books, desc: "MOC "} - - {id: 31, cat: Books, desc: "CareerAcademy"} - - {id: 32, cat: Books, desc: "CBTNuggets"} - - {id: 33, cat: Books, desc: "INE"} - - {id: 34, cat: Books, desc: "Symantec"} - - {id: 35, cat: Books, desc: "Infiniteskills"} - - {id: 36, cat: Books, desc: "IPExpert"} - - {id: 37, cat: Books, desc: "Tuts+"} - - {id: 38, cat: Books, desc: "Pluralsight"} - - {id: 39, cat: Books/EBook, desc: "E-Books"} - - {id: 40, cat: Books, desc: "CBT-Videos"} - - {id: 41, cat: Books, desc: "Linux-CBT"} - - {id: 42, cat: Books, desc: "VTC"} - - {id: 43, cat: Books, desc: "Lynda"} - - {id: 44, cat: Books, desc: "TrainSignal"} - - {id: 50, cat: Books, desc: "LiveLessons"} - - {id: 47, cat: Books, desc: "Health"} - - {id: 48, cat: Books/Mags, desc: "Magazines"} - - {id: 49, cat: Books, desc: "CourseWare"} - - {id: 51, cat: Books, desc: "Programming"} - - {id: 52, cat: Books, desc: "Graphic & Design"} - - {id: 53, cat: Books, desc: "Udemy"} - - {id: 45, cat: Books/Other, desc: "Others"} - - {id: 54, cat: Books, desc: "SAP"} - - {id: 46, cat: Books, desc: "LF VIP"} - - {id: 55, cat: Books, desc: "Exclusive"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and check that you are not being forced to change your password because it has expired after 180 days." - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - # 15 results per page - - path: / - inputs: - page: 1 - - path: / - inputs: - page: 2 - - path: / - inputs: - page: 3 - inputs: - p: torrents - pid: 10 - $raw: "{{ range .Categories }}&cid[]={{.}}{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - - rows: - selector: "div.torrent-box[id^=\"torrent_\"]{{ if .Config.freeleech }}:has(img[src$=\"/torrent_free.png\"]){{ else }}{{ end }}" - - fields: - title: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - details: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - attribute: href - category: - selector: a[href*="?p=torrents&pid=10&cid="] - attribute: href - filters: - - name: querystring - args: cid - download: - selector: a[href*="?p=torrents&pid=10&action=download&tid="] - attribute: href - poster: - selector: a[href*="data/torrents/torrent_images/"] - attribute: href - genre: - selector: div.torrentDetails > div:has(a[href*="&genre="]) - filters: - - name: replace - args: ["Genre:", ""] - description: - text: "{{ .Result.genre }}" - size: - selector: a[rel="torrent_size"] - grabs: - selector: a[rel="times_completed"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - date_day: - selector: div.torrentOwner:contains("day") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - selector: div.torrentOwner:contains("-") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - selector: div.torrentOwner abbr - optional: true - attribute: data-time - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img[src$="/torrent_free.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/leech24.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/leech24.yml deleted file mode 100644 index c96dbc1a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/leech24.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: leech24 -name: leech24 -description: "leech24 is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://leech24.net/ - -caps: - categorymappings: - - {id: 28, cat: TV/Anime, desc: "Anime - All"} - - {id: 20, cat: PC, desc: "Apps - Linux"} - - {id: 19, cat: PC/Mac, desc: "Apps - Mac"} - - {id: 21, cat: PC/Mobile-Other, desc: "Apps - Other"} - - {id: 18, cat: PC/0day, desc: "Apps - PC"} - - {id: 9, cat: TV/Documentary, desc: "Documentaries - All"} - - {id: 16, cat: Console/Other, desc: "Games - Dreamcast"} - - {id: 45, cat: Console/NDS, desc: "Games - DS"} - - {id: 46, cat: Console/Other, desc: "Games - GameCube"} - - {id: 49, cat: Console/NDS, desc: "Games - Nintendo"} - - {id: 17, cat: Console/Other, desc: "Games - Other"} - - {id: 10, cat: PC/Games, desc: "Games - PC"} - - {id: 11, cat: Console/PS3, desc: "Games - PS"} - - {id: 12, cat: Console/PSP, desc: "Games - PSP"} - - {id: 44, cat: Console/Wii, desc: "Games - Wii"} - - {id: 13, cat: Console/XBox, desc: "Games - Xbox"} - - {id: 3, cat: Movies/SD, desc: "Movies - 480p"} - - {id: 57, cat: Movies/UHD, desc: "Movies - 4K"} - - {id: 50, cat: Movies/BluRay, desc: "Movies - Bluray"} - - {id: 51, cat: Movies/BluRay, desc: "Movies - Bluray-Full"} - - {id: 1, cat: Movies/DVD, desc: "Movies - DVD"} - - {id: 42, cat: Movies/HD, desc: "Movies - HD"} - - {id: 54, cat: Movies/HD, desc: "Movies - MP4"} - - {id: 4, cat: Movies/Other, desc: "Movies - Other"} - - {id: 47, cat: Movies, desc: "Movies - Packs"} - - {id: 2, cat: Movies/SD, desc: "Movies - SD/Xvid"} - - {id: 61, cat: Movies/WEB-DL, desc: "Movies - Web-DL"} - - {id: 56, cat: Movies/HD, desc: "Movies - x265"} - - {id: 24, cat: Audio/Video, desc: "Music - DVD"} - - {id: 23, cat: Audio/Lossless, desc: "Music - Lossless"} - - {id: 22, cat: Audio/MP3, desc: "Music - MP3"} - - {id: 27, cat: Audio/Other, desc: "Music - Other"} - - {id: 26, cat: Audio/Other, desc: "Music - Radio"} - - {id: 25, cat: Audio/Video, desc: "Music - Video"} - - {id: 36, cat: Books/EBook, desc: "Other - E-Books"} - - {id: 33, cat: PC, desc: "Other - Emulation"} - - {id: 39, cat: Other, desc: "Other - Extra Pars/Fills"} - - {id: 37, cat: Other, desc: "Other - Images"} - - {id: 38, cat: PC/Mobile-Other, desc: "Other - Mobile Phone"} - - {id: 40, cat: Other, desc: "Other - Other"} - - {id: 34, cat: PC/Mobile-Other, desc: "Other - PPC/PDA"} - - {id: 35, cat: Audio, desc: "Other - Sounds"} - - {id: 58, cat: TV/SD, desc: "TV - 480p"} - - {id: 52, cat: TV/UHD, desc: "TV - Bluray"} - - {id: 6, cat: TV/SD, desc: "TV - Divx/Xvid"} - - {id: 5, cat: TV/SD, desc: "TV - DVD-R"} - - {id: 59, cat: TV/SD, desc: "TV - DVD-Rip"} - - {id: 48, cat: TV/Other, desc: "TV - Packs"} - - {id: 53, cat: TV/SD, desc: "TV - SD/x264"} - - {id: 7, cat: TV/SD, desc: "TV - SVCD/VCD"} - - {id: 62, cat: TV/WEB-DL, desc: "TV - Web-DL"} - - {id: 41, cat: TV/HD, desc: "TV - x264"} - - {id: 60, cat: TV/HD, desc: "TV - x265"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href="account-logout.php"] - -search: - paths: - # https://leech24.net/torrents-search.php?&search=&incldead=1&freeleech=0&lang=0 - - path: torrents-search.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" -# cat: 0 # causes 0 results - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 notfree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 all, 1 english, etc - lang: 0 - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # does not support imdbid search and does not return imdb link in results - - keywordsfilters: - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: table.ttable_headinner tr.t-row:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="torrents.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="torrents-details.php?id="] - attribute: title - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date: - text: now - size: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - img[src="images/free.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.95 -# TT3.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lemonhd-net.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lemonhd-net.yml deleted file mode 100644 index fd420f8a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lemonhd-net.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: lemonhd-net -name: LemonHD.net -description: "LemonHD.net is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://lemonhd.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: Audio, desc: "Misc"} - - {id: 403, cat: TV/Anime, desc: "Animations"} - - {id: 404, cat: Audio/Video, desc: "Music Videos"} - - {id: 405, cat: TV/Documentary, desc: "Documentaries"} - - {id: 406, cat: TV, desc: "TV Series"} - - {id: 407, cat: TV, desc: "TV Shows"} - - {id: 408, cat: Movies/3D, desc: "3D"} - - {id: 409, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href$="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 justdead - incldead: 0 - # promotion: 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x 50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdb - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.10.0 2026-02-01 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lesaloon.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lesaloon.yml deleted file mode 100644 index 7d4212e6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lesaloon.yml +++ /dev/null @@ -1,228 +0,0 @@ ---- -id: lesaloon -name: LeSaloon -description: "Le Saloon is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://lesaloonv2-0.lol/ - - https://lesaloonv2-0.net/ -legacylinks: - - http://lesaloonv2-0.net/ # site forces https - - https://lesaloonv2-0.xyz/ - -caps: - categorymappings: - - {id: 13, cat: Movies/BluRay, desc: "Films BD-Rip / BRRip"} - - {id: 19, cat: Movies/WEB-DL, desc: "Films WEB-DL / WEB-Rip"} - - {id: 15, cat: Movies/DVD, desc: "Films DVD-Rip"} - - {id: 18, cat: Movies/HD, desc: "Films HD 720p"} - - {id: 20, cat: Movies/HD, desc: "Films HD 1080p"} - - {id: 26, cat: Movies/HD, desc: "Films mHD 720p"} - - {id: 27, cat: Movies/HD, desc: "Films mHD 1080p"} - - {id: 28, cat: Movies/HD, desc: "Films x265"} - - {id: 29, cat: Movies, desc: "Films Remux"} - - {id: 30, cat: Movies/BluRay, desc: "Films Blu-Ray (complet)"} - - {id: 31, cat: Movies/3D, desc: "Films 3D"} - - {id: 32, cat: Movies/UHD, desc: "Films 4K UHD"} - - {id: 33, cat: Movies/SD, desc: "Films Québécois SD"} - - {id: 34, cat: Movies/HD, desc: "Films Québécois HD"} - - {id: 35, cat: Movies/DVD, desc: "Films Québécois DVD-R"} - - {id: 37, cat: Movies/DVD, desc: "Films DVD-R"} - - {id: 38, cat: Movies/SD, desc: "Films V.O.S.T. SD"} - - {id: 39, cat: Movies/HD, desc: "Films V.O.S.T. HD"} - - {id: 40, cat: Movies/SD, desc: "Films V.O. SD"} - - {id: 41, cat: Movies/HD, desc: "Films V.O. HD"} - - {id: 42, cat: Movies, desc: "Films TV-Rip"} - - {id: 21, cat: TV/SD, desc: "Séries-Télé SD"} - - {id: 22, cat: TV/HD, desc: "Séries-Télé HD"} - - {id: 17, cat: TV/SD, desc: "Séries-Télé Pack SD"} - - {id: 23, cat: TV/HD, desc: "Séries-Télé Pack HD"} - - {id: 45, cat: TV/SD, desc: "Séries-Télé Québécois SD"} - - {id: 46, cat: TV/HD, desc: "Séries-Télé Québécois HD"} - - {id: 47, cat: TV/SD, desc: "Séries-Télé V.O.S.T. SD"} - - {id: 48, cat: TV/HD, desc: "Séries-Télé V.O.S.T. HD"} - - {id: 49, cat: TV/SD, desc: "Séries-Télé Émissions TV SD"} - - {id: 50, cat: TV/HD, desc: "Séries-Télé Émissions TV HD"} - - {id: 51, cat: TV, desc: "Séries-Télé Blu-Ray"} - - {id: 52, cat: TV, desc: "Séries-Télé DVD-R"} - - {id: 43, cat: TV/SD, desc: "Séries-Télé Pack SD Québec"} - - {id: 44, cat: TV/HD, desc: "Séries-Télé Pack HD Québec"} - - {id: 77, cat: TV/Documentary, desc: "Docu. SD"} - - {id: 78, cat: TV/Documentary, desc: "Docu. HD"} - - {id: 79, cat: TV/Documentary, desc: "Docu. V.O.S.T. SD"} - - {id: 80, cat: TV/Documentary, desc: "Docu. V.O.S.T. HD"} - - {id: 85, cat: TV/Documentary, desc: "Docu-Série SD"} - - {id: 86, cat: TV/Documentary, desc: "Docu-Série HD"} - - {id: 87, cat: TV/Documentary, desc: "Docu-Pack SD"} - - {id: 88, cat: TV/Documentary, desc: "Docu-Pack HD"} - - {id: 54, cat: Audio/MP3, desc: "Musique MP3"} - - {id: 55, cat: Audio/Lossless, desc: "Musique FLAC"} - - {id: 56, cat: Audio, desc: "Musique DSD"} - - {id: 57, cat: Audio, desc: "Musique Karaoke"} - - {id: 58, cat: Audio/Other, desc: "Musique Autres"} - - {id: 59, cat: PC/Games, desc: "Jeux PC"} - - {id: 60, cat: Console, desc: "Jeux Consoles"} - - {id: 61, cat: Books/EBook, desc: "Livres epub"} - - {id: 62, cat: Books/EBook, desc: "Livres pdf"} - - {id: 63, cat: Books/Mags, desc: "Livres Journaux"} - - {id: 64, cat: Books/Mags, desc: "Livres Magazines"} - - {id: 65, cat: Books/Comics, desc: "Livres Bandes Dessinées"} - - {id: 66, cat: Books/Other, desc: "Livres Multi-Format"} - - {id: 68, cat: PC/0day, desc: "Logiciels Windows"} - - {id: 69, cat: PC/Mac, desc: "Logiciels Mac"} - - {id: 71, cat: TV/Other, desc: "Spectacles Humour"} - - {id: 72, cat: TV/Other, desc: "Spectacles Live"} - - {id: 84, cat: TV/Other, desc: "Théâtre"} - - {id: 83, cat: TV/Anime, desc: "Séries Animées"} - - {id: 74, cat: TV/Sport, desc: "Sports [Français]"} - - {id: 75, cat: TV/Sport, desc: "Sports [Anglais]"} - - {id: 82, cat: Other, desc: "Autres / Inclassable"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: vfq - type: checkbox - label: Replace VFQ with FRENCH - default: false - -login: - # https://lesaloonv2-0.xyz/yupy_login.php - path: yupy_login.php - method: form - form: form[action^="login"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - loginButton: XZ - error: - - selector: "p[style=\"color: #B73C38\"]" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # http://lesaloonv2-0.xyz/index.php?page=torrents&search=supernatural+s13e20&parentcategory=0&category=0&genre=0&options=0&active=0 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - parentcategory: 0 - category: 0 - genre: 0 - # 0 name, 1 name & descr, 2 desc, 3 uploaders, 5 gold, 6 silver, 7 bronze - options: "{{ if .Config.freeleech }}5{{ else }}0{{ end }}" - # 0 all, 1 active, 2 dead - active: 0 - # does not support imdbid searching and does not have imdb links in results - - rows: - selector: table.torrentlist tbody tr:has(a[href^="index.php?page=torrent-details"]) - - fields: - category: - selector: td a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - title_phase1: - selector: td a[href^="index.php?page=torrent-details"] - title_vfq: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(VFQ)\\b", "FRENCH"] - title_phase2: - text: "{{ if .Config.vfq }}{{ .Result.title_vfq }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase3: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase3 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase3 }}{{ end }}" - details: - selector: td a[href^="index.php?page=torrent-details"] - attribute: href - download: - selector: td a[href^="download.php?id="] - attribute: href - poster: - selector: a[href^="index.php?page=torrent-details"][onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.*?) " - date: - # 06/02/2019 18:19:28 - selector: td:nth-child(7) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd/MM/yyyy HH:mm:ss zzz" - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - grabs: - selector: td:nth-child(10) - size: - selector: td:nth-child(12) - downloadvolumefactor: - case: - img[src="images/freeleech.gif"]: 0 - img[src="images/gold.gif"]: 0 - img[src="images/silver.gif"]: 0.5 - img[src="images/bronze.gif"]: 0.25 - "*": 1 - uploadvolumefactor: - case: -# img[src="images/bronze.gif"]: "2" # its in the rules as x2up, but all the bronze i've see so far are just 1/4 download in the title. - "*": 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lesbians4u.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lesbians4u.yml deleted file mode 100644 index 9c2c5696..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lesbians4u.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: lesbians4u -name: Lesbians4u -description: "Lesbians4u is a Private Torrent Tracker for 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - http://lesbians4u.org/ # does not support https, CA invalid - -caps: - categorymappings: - - {id: 7, cat: XXX, desc: "Amateur"} - - {id: 12, cat: XXX, desc: "Anime &Hentai &JAV"} - - {id: 17, cat: XXX, desc: "Appz"} - - {id: 3, cat: XXX, desc: "Classics"} - - {id: 6, cat: XXX, desc: "Clips &Siterips"} - - {id: 16, cat: XXX, desc: "DVD"} - - {id: 15, cat: XXX, desc: "EX-V.I.P. Torrents"} - - {id: 9, cat: XXX, desc: "Fetish"} - - {id: 8, cat: XXX, desc: "Games"} - - {id: 11, cat: XXX, desc: "Misc."} - - {id: 10, cat: XXX, desc: "Movie"} - - {id: 5, cat: XXX, desc: "Solo"} - - {id: 14, cat: XXX, desc: "V.I.P."} - - {id: 13, cat: XXX, desc: "Watersport"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To keep records updated regularly, all inactive accounts will be deleted after 60 days of inactivity." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - error: - - selector: table:has(a[href*="login.php?error=1"]) - message: - text: "ERROR: Incorrect username!" - - selector: table:has(a[href*="login.php?error=4"]) - message: - text: "ERROR: Incorrect password!" - - selector: table:has(a[href*="login.php?error="]) - message: - text: "ERROR: Something went wrong during login!" - test: - path: index.php - selector: a[href*="/logout.php?logouthash="] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ re_replace .DownloadUri.PathAndQuery \".*torrent-details-(\\d+).*\" \"$1\"}}" - selectors: - - selector: a[href*="download-torrent-"] - attribute: href - -search: - paths: - - path: browse.php - - keywordsfilters: - # prefix and postfix percent wildcard as well as replacing spaces between words - - name: re_replace - args: ["\\W+", "%"] - - name: re_replace - args: ["^(.+)$", "%$1%"] - - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_name - # does not support multi category searching so defaulting to all. - category: 0 - include_dead_torrents: yes - sort: "{{ if .Config.freeleech }}free{{ else }}{{ .Config.sort }}{{ end }}" - order: "{{ if .Config.freeleech }}asc{{ else }}{{ .Config.type }}{{ end }}" - - rows: - selector: "table.sortable tr:has(a[href*=\"/download-torrent-\"]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="torrent-category-"] - attribute: href - filters: - - name: regexp - args: torrent-category-(\d+) - title_default: - # is often abbreviated - selector: a[href*="torrent-details-"] - title: - # usually longer than details title - selector: div[id^="port-content-"] div - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="torrent-details-"] - attribute: href - download: - selector: a[href*="torrent-details-"] - attribute: href - poster: - selector: div[id^="port-content-"] img - attribute: src - date: - selector: td:nth-child(2) > div:last-child - filters: - - name: regexp - args: (\d{2}-\d{2}-\d{4} \d{2}:\d{2}) - - name: append - args: " +00:00" # auto adjusted by site account profile - - name: dateparse - args: "dd-MM-yyyy HH:mm zzz" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - img[src$="/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 -# TS Special Edition v.7.3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/letseed.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/letseed.yml deleted file mode 100644 index e7c61c2e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/letseed.yml +++ /dev/null @@ -1,211 +0,0 @@ ---- -id: letseed -name: LetSeed -description: "LetSeed is a TURKISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: tr-TR -type: private -encoding: UTF-8 -links: - - https://letseed.org/ - -caps: - categorymappings: - - {id: 3, cat: TV, desc: "Diziler"} - - {id: 5, cat: TV/Foreign, desc: "Yabancı Diziler"} - - {id: 6, cat: TV/Other, desc: "Yerli Dizi Boxset"} - - {id: 4, cat: TV, desc: "Yerli Diziler"} - - {id: 44, cat: Books, desc: "Eğitim"} - - {id: 46, cat: Books/EBook, desc: "E-Book"} - - {id: 45, cat: Books/Other, desc: "Eğitim"} - - {id: 1, cat: Movies, desc: "Filmler"} - - {id: 15, cat: Movies/UHD, desc: "4K"} - - {id: 2, cat: Movies/Other, desc: "Animasyonlar"} - - {id: 16, cat: Movies/Other, desc: "BoxSet"} - - {id: 14, cat: Movies/Foreign, desc: "Yabancı Yapımlar"} - - {id: 13, cat: Movies, desc: "Yerli Yapımlar"} - - {id: 32, cat: Audio, desc: "Müzik"} - - {id: 35, cat: Audio/Other, desc: "Diskografi"} - - {id: 34, cat: Audio/Foreign, desc: "Yabancı"} - - {id: 33, cat: Audio, desc: "Yerli"} - - {id: 28, cat: PC/Games, desc: "Oyun"} - - {id: 29, cat: PC/Games, desc: "PC"} - - {id: 31, cat: Console/PS4, desc: "Playstation"} - - {id: 30, cat: Console/XBox, desc: "XBOX"} - - {id: 19, cat: Other, desc: "Platform Sunumları"} - - {id: 21, cat: Movies, desc: "AMZN"} - - {id: 23, cat: Movies, desc: "BluTv"} - - {id: 22, cat: TV, desc: "DSNP"} - - {id: 24, cat: TV, desc: "Exxen"} - - {id: 26, cat: TV, desc: "GAIN"} - - {id: 47, cat: TV, desc: "MAX"} - - {id: 20, cat: Movies, desc: "Netflix"} - - {id: 42, cat: TV, desc: "Tabii"} - - {id: 25, cat: TV, desc: "TOD"} - - {id: 27, cat: TV, desc: "TV+"} - - {id: 36, cat: PC, desc: "Program"} - - {id: 37, cat: PC/ISO, desc: "İşletim Sistemi"} - - {id: 38, cat: PC/Mac, desc: "Mac"} - - {id: 39, cat: PC/0day, desc: "Windows"} - - {id: 40, cat: TV/Sport, desc: "Spor"} - - {id: 41, cat: TV/Sport, desc: "Spor Sunumları"} - - {id: 17, cat: TV, desc: "VIP Sunumlar"} - - {id: 18, cat: TV, desc: "VIP Sunumlar"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 40 on your account profile. The Default is 30. - - name: info_activity - type: info - label: Account Inactivity - default: "To keep records updated regularly, all inactive accounts will be deleted after 60 days of inactivity." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img#regimage - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - error: - - selector: table:has(td:contains("Bir hata oluştu")) - - selector: table:has(a[href*="login.php?error=1"]) - message: - text: "Incorrect username!" - - selector: table:has(a[href*="login.php?error=2"]) - message: - text: "The image verification code you entered was not approved." - - selector: table:has(a[href*="login.php?error=3"]) - message: - text: "Please do not leave required fields blank!" - - selector: table:has(a[href*="login.php?error=4"]) - message: - text: "Incorrect password!" - - selector: table:has(a[href*="login.php?error="]) - message: - text: "Something went wrong during login!" - test: - path: browse.php - selector: a[href*="/logout.php"] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ .DownloadUri.Query.id }}" - -search: - paths: - - path: browse.php - method: post - keywordsfilters: - - name: re_replace - args: ["[\\s]+", "%"] - inputs: - do: search - keywords: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: "{{ if .Query.IMDBID }}t_genre{{ else }}t_name{{ end }}" - # does not support multi category searching so defaulting to all. - category: 0 - # yes, no - include_dead_torrents: yes - sort: "{{ if .Config.freeleech }}free{{ else }}{{ .Config.sort }}{{ end }}" - order: "{{ if .Config.freeleech }}asc{{ else }}{{ .Config.type }}{{ end }}" - - rows: - selector: "table.sortable tr:has(a[href*=\"download.php?id=\"]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - title_default: - # is usually abbreaviated - selector: a[href*="details.php?id="] - title: - # while still abbreviated, is usually longer than the default - selector: div.tooltip-content > div - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[src*="/torrents/images/"] - attribute: src - date: - selector: td:nth-child(2) - # auto adjusted by site account profile - filters: - - name: regexp - args: "(\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2})" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - img[src$="/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="/x2.gif"]: 2 - "*": 1 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# TS Special Edition v.8.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/libranet.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/libranet.yml deleted file mode 100644 index 7661f781..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/libranet.yml +++ /dev/null @@ -1,131 +0,0 @@ ---- -id: libranet -name: LibraNet -description: "LibraNet (LN) is a HUNGARIAN Private Torrent Tracker for EBOOKS / LOSSLESS MUSIC" -language: hu-HU -type: private -encoding: ISO-8859-2 -links: - - https://libranet.org/ - -caps: - categorymappings: - - {id: 50, cat: Movies, desc: "Film"} - - {id: 51, cat: Movies, desc: "Film/Hun"} - - {id: 3, cat: Audio/Audiobook, desc: "Hangoskönyv"} - - {id: 4, cat: Audio/Audiobook, desc: "Hangoskönyv/Hun"} - - {id: 1, cat: Books/EBook, desc: "Könyv"} - - {id: 2, cat: Books/EBook, desc: "Könyv/Hun"} - - {id: 56, cat: Audio/Lossless, desc: "LossLess"} - - {id: 57, cat: Audio/Lossless, desc: "LossLess/Hun"} - - {id: 48, cat: Books/Mags, desc: "Magazin"} - - {id: 49, cat: Books/Mags, desc: "Magazin/Hun"} - - modes: - search: [q] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_activity - type: info - label: Account Inactivity - default: "If you do not visit the site for 90 days, the system may delete it due to inactivity." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.text:contains("Error") - message: - selector: td.text - test: - path: / - selector: a[href="/logout.php"] - -search: - paths: - # http://libranet.org/browse.php?cat56=1&cat57=1&search=&searchwhere=&subcat=&musicstyle=&format= - - path: browse.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # empty () author and title, (eloado) author only, (album) title only, (language), (catalognum), (series) series, (szeria) series, (polc) shelf - searchwhere: "" - subcat: "" - musicstyle: "" - format: "" - incldead: 1 - - rows: - selector: table.rounded tr:has(a[href^="browse.php?cat="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - _format: - selector: img[width="60"] - attribute: title - title: - selector: a[href^="details.php?id="] - filters: - - name: append - args: " {{ .Result._format }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details", "download"] - - name: replace - args: ["&hit=1", ""] - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - date: - selector: td:nth-last-child(7) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-last-child(6) - grabs: - selector: td:nth-last-child(5) - seeders: - selector: td:nth-last-child(4) - leechers: - selector: td:nth-last-child(3) - downloadvolumefactor: - case: - img[src$="/pic/freeleech.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - genre: - selector: td > table - remove: b - description: - text: "{{ .Result._format }}{{ if .Result.genre }} - {{ .Result.genre }}{{ else }}{{ end }}" -# TBdev diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/limetorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/limetorrents.yml deleted file mode 100644 index 89238c7d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/limetorrents.yml +++ /dev/null @@ -1,138 +0,0 @@ ---- -id: limetorrents -name: LimeTorrents -description: "LimeTorrents is a Public general torrent index with mostly verified torrents" -language: en-US -type: public -encoding: UTF-8 -# changes to this indexer should also be made to limetorrentsclone -links: - - https://www.limetorrents.fun/ - - https://limetorrents.unblockninja.com/ - - https://limetorrents.ninjaproxy1.com/ - - https://limetorrents.proxyninja.org/ - - https://limetorrents.proxyninja.net/ - - https://limetorrents.torrentbay.st/ - - https://limetorrents.torrentsbay.org/ -legacylinks: - - https://limetorrents.mrunblock.bond/ - - https://limetorrents.nocensor.cloud/ - - https://limetorrents.abcproxy.org/ - - https://limetorrents.unblockit.download/ - - https://www.limetorrents.lol/ - -caps: - categorymappings: - - {id: "TV shows", cat: TV, desc: "TV shows"} - - {id: Movies, cat: Movies, desc: Movies} - - {id: Music, cat: Audio, desc: Music} - - {id: Games, cat: Console, desc: Games} - - {id: Applications, cat: PC/0day, desc: Applications} - - {id: Other, cat: Other, desc: Other} - - {id: Anime, cat: TV/Anime, desc: Anime} - - {id: E-books, cat: Books/EBook, desc: E-books} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: downloadlink - type: select - label: Download link - default: "magnet:" - options: - "http://itorrents.org/": iTorrents.org - "magnet:": magnet - - name: downloadlink2 - type: select - label: Download link (fallback) - default: "http://itorrents.org/" - options: - "http://itorrents.org/": iTorrents.org - "magnet:": magnet - - name: info_download - type: info - label: About the Download links - default: As the .torrent download links on this site are known to fail from time to time, you can optionally set as a fallback an automatic alternate link. - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - seeds: seeders - size: size - - name: info_category_8000 - type: info - label: About LimeTorrents Categories - default: LimeTorrents only returns category Other in its Keywordless search results page.
To pass your apps' indexer TEST you will need to include the 8000(Other) category. - -download: - # the .torrent url is on the on the details page - selectors: - - selector: a.csprite_dltorrent[href^="{{ .Config.downloadlink }}"] - attribute: href - - selector: a.csprite_dltorrent[href^="{{ .Config.downloadlink2 }}"] - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}search/all/{{ .Keywords }}/{{ .Config.sort }}/1/{{ else }}/latest100{{ end }}" - keywordsfilters: - - name: re_replace - args: ["S[0-9]{2}([^E]|$)", ""] # remove season tag without episode - - rows: - selector: .table2 > tbody > tr[bgcolor] - - fields: - title: - selector: div.tt-name > a[href^="/"] - attribute: href - filters: - - name: regexp - args: "/(.+?)-torrent-\\d+\\.html" - - name: re_replace - args: ["-", " "] - category_is_tv_show: - text: "{{ .Result.title }}" - filters: - - name: regexp - args: "\\b(S\\d+(?:E\\d+)?)\\b" - category: - selector: td:nth-child(2) - optional: true - default: "{{ if .Result.category_is_tv_show }}TV shows{{ else }}Other{{ end }}" - filters: - - name: regexp - args: " in (.+?)[.]?$" - details: - selector: div.tt-name > a[href^="/"] - attribute: href - download: - selector: div.tt-name > a[href^="/"] - attribute: href - date: - selector: td:nth-child(2) - filters: - - name: split - args: ["-", 0] - - name: replace - args: ["Last Month", "1 month ago"] - - name: replace - args: ["+", " ago"] - size: - selector: td:nth-child(3) - seeders: - selector: .tdseed - leechers: - selector: .tdleech - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/linkomanija.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/linkomanija.yml deleted file mode 100644 index ed2f056a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/linkomanija.yml +++ /dev/null @@ -1,169 +0,0 @@ ---- -id: linkomanija -name: LinkoManija -description: "LinkoManija is an LITHUANIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: lt-LT -type: private -encoding: UTF-8 -links: - - https://www.linkomanija.net/ - -caps: - categorymappings: - - {id: 29, cat: Movies/SD, desc: "Movies"} - - {id: 52, cat: Movies/HD, desc: "Movies HD"} - - {id: 30, cat: TV/SD, desc: "TV"} - - {id: 60, cat: TV/HD, desc: "TV HD"} - - {id: 45, cat: PC/Games, desc: "PC Games"} - - {id: 37, cat: Audio, desc: "Music"} - - {id: 56, cat: Books, desc: "Books"} - - {id: 40, cat: Other/Misc, desc: "Misc"} - - {id: 53, cat: Movies/SD, desc: "Movies LT"} - - {id: 61, cat: Movies/HD, desc: "Movies LT HD"} - - {id: 28, cat: TV/SD, desc: "TV LT"} - - {id: 62, cat: TV/HD, desc: "TV LT HD"} - - {id: 63, cat: PC/Games, desc: "PC Games Misc"} - - {id: 33, cat: Audio, desc: "Music LT"} - - {id: 57, cat: Books, desc: "Books LT"} - - {id: 43, cat: Other/Misc, desc: "Misc LT"} - - {id: 51, cat: Movies/SD, desc: "Movies RU"} - - {id: 64, cat: Movies/HD, desc: "Movies RU HD"} - - {id: 65, cat: TV, desc: "TV RU"} - - {id: 25, cat: Movies/DVD, desc: "DVD"} - - {id: 42, cat: Console/XBox, desc: "XBOX"} - - {id: 66, cat: Audio/Lossless, desc: "Music Lossless"} - - {id: 41, cat: PC/Mac, desc: "MAC"} - - {id: 69, cat: PC/Mobile-Android, desc: "Android"} - - {id: 46, cat: TV/Sport, desc: "Sports"} - - {id: 58, cat: TV/Documentary, desc: "Documentaries"} - - {id: 38, cat: TV/Anime, desc: "Anime"} - - {id: 26, cat: Movies/DVD, desc: "DVD LT"} - - {id: 44, cat: Console/Other, desc: "Consoles"} - - {id: 31, cat: Audio/Video, desc: "Music Videos"} - - {id: 32, cat: PC/0day, desc: "Appz"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - name: title - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentų puslapyje setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive users will be deleted after 90 days. The newly created account with 0 sent data will be deleted after 28 days." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.klaida - test: - path: browse.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 incldead, 2 dead - incldead: 1 - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - searchindesc: "{{ if .Query.IMDBID }}1{{ else }}{{ end }}" - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: table> tbody > tr:has(a[href^="details?"]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details?"] - details: - selector: a[href^="details?"] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - genre: - selector: td:nth-child(2) > span - filters: - - name: re_replace - args: [" \\| .+", ""] - description: - selector: td:nth-child(2) > span - files: - selector: td:nth-child(3) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src$="/images/flag_green.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.3 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/linuxtracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/linuxtracker.yml deleted file mode 100644 index fe25a4b7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/linuxtracker.yml +++ /dev/null @@ -1,392 +0,0 @@ ---- -id: linuxtracker -name: LinuxTracker -description: "LinuxTracker is a Public Linux ISO Torrent Repository" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://linuxtracker.org/ - -caps: - categorymappings: - - {id: 0, cat: Other, desc: "Other"} - - {id: 2002, cat: PC/ISO, desc: "4MLinux"} - - {id: 2004, cat: PC/ISO, desc: "ALT Linux"} - - {id: 2289, cat: PC/ISO, desc: "APODIO"} - - {id: 2012, cat: PC/ISO, desc: "AV Linux"} - - {id: 13, cat: PC/ISO, desc: "Absolute"} - - {id: 2003, cat: PC/ISO, desc: "Alpine Linux"} - - {id: 2292, cat: PC/ISO, desc: "Anarchy Linux"} - - {id: 2005, cat: PC/ISO, desc: "Android x86"} - - {id: 2006, cat: PC/ISO, desc: "Antergos"} - - {id: 2007, cat: PC/ISO, desc: "AntiX"} - - {id: 2263, cat: PC/ISO, desc: "Antivirus Live CD"} - - {id: 39, cat: PC/ISO, desc: "Arch Linux"} - - {id: 2212, cat: PC/ISO, desc: "ArchLabs"} - - {id: 2009, cat: PC/ISO, desc: "Archbang"} - - {id: 2183, cat: PC/ISO, desc: "Archman"} - - {id: 2196, cat: PC/ISO, desc: "ArcoLinux"} - - {id: 2216, cat: PC/ISO, desc: "Artix Linux"} - - {id: 2227, cat: PC/ISO, desc: "AryaLinux"} - - {id: 2288, cat: PC/ISO, desc: "Audiophile Linux"} - - {id: 56, cat: PC/ISO, desc: "Austrumi"} - - {id: 2252, cat: PC/ISO, desc: "BBQLinux"} - - {id: 2260, cat: PC/ISO, desc: "BSD Router Project"} - - {id: 2219, cat: PC/ISO, desc: "BackSlash Linux"} - - {id: 1231, cat: PC/ISO, desc: "Backbox"} - - {id: 2237, cat: PC/ISO, desc: "Batocera Linux"} - - {id: 2272, cat: PC/ISO, desc: "BeeFree OS"} - - {id: 2017, cat: PC/ISO, desc: "Berry Linux"} - - {id: 2301, cat: PC/ISO, desc: "Berserk Arch"} - - {id: 2243, cat: PC/ISO, desc: "BicomSystems PBXware"} - - {id: 2275, cat: PC/ISO, desc: "BigLinux"} - - {id: 2286, cat: PC/ISO, desc: "BitKey"} - - {id: 2020, cat: PC/ISO, desc: "BlackArch"} - - {id: 1238, cat: PC/ISO, desc: "BlackPanther OS"} - - {id: 2262, cat: PC/ISO, desc: "BlackWeb"} - - {id: 2283, cat: PC/ISO, desc: "Blackhat-Global OS"} - - {id: 2021, cat: PC/ISO, desc: "Blacklab"} - - {id: 2022, cat: PC/ISO, desc: "Blag"} - - {id: 79, cat: PC/ISO, desc: "BlankOn"} - - {id: 2182, cat: PC/ISO, desc: "Bluestar Linux"} - - {id: 2023, cat: PC/ISO, desc: "Bodhi"} - - {id: 2205, cat: PC/ISO, desc: "BunsenLabs"} - - {id: 2026, cat: PC/ISO, desc: "Cae"} - - {id: 1220, cat: PC/ISO, desc: "Caine"} - - {id: 2028, cat: PC/ISO, desc: "Calculate Linux"} - - {id: 102, cat: PC/ISO, desc: "CentOS"} - - {id: 1216, cat: PC/ISO, desc: "Chakra"} - - {id: 2253, cat: PC/ISO, desc: "Clear Linux"} - - {id: 2264, cat: PC/ISO, desc: "ClearOS"} - - {id: 2248, cat: PC/ISO, desc: "ClonOS"} - - {id: 107, cat: PC/ISO, desc: "Clonezilla"} - - {id: 2257, cat: PC/ISO, desc: "Condres OS"} - - {id: 2037, cat: PC/ISO, desc: "Container Linux"} - - {id: 121, cat: PC/ISO, desc: "Crunchbang"} - - {id: 122, cat: PC/ISO, desc: "Crux"} - - {id: 2273, cat: PC/ISO, desc: "Cryptocurrency OS"} - - {id: 2245, cat: PC/ISO, desc: "Cucumber Linux"} - - {id: 2285, cat: PC/ISO, desc: "DRBL Live"} - - {id: 123, cat: PC/ISO, desc: "Damnsmall"} - - {id: 128, cat: PC/ISO, desc: "Debian"} - - {id: 131, cat: PC/ISO, desc: "Deepin"} - - {id: 2038, cat: PC/ISO, desc: "Deft"} - - {id: 2192, cat: PC/ISO, desc: "Devuan"} - - {id: 2287, cat: PC/ISO, desc: "Doge Linux"} - - {id: 144, cat: PC/ISO, desc: "DragonFly BSD"} - - {id: 2226, cat: PC/ISO, desc: "DuZeru OS"} - - {id: 148, cat: PC/ISO, desc: "Dynebolic"} - - {id: 2261, cat: PC/ISO, desc: "Eagle Mode"} - - {id: 2265, cat: PC/ISO, desc: "EasyNAS"} - - {id: 2259, cat: PC/ISO, desc: "EasyOS"} - - {id: 155, cat: PC/ISO, desc: "Edubuntu"} - - {id: 2040, cat: PC/ISO, desc: "Elastix"} - - {id: 2041, cat: PC/ISO, desc: "Elementary OS"} - - {id: 161, cat: PC/ISO, desc: "Elive"} - - {id: 2042, cat: PC/ISO, desc: "Emmabuntus"} - - {id: 2278, cat: PC/ISO, desc: "EndeavourOS"} - - {id: 164, cat: PC/ISO, desc: "Endian"} - - {id: 2179, cat: PC/ISO, desc: "Endless OS"} - - {id: 2223, cat: PC/ISO, desc: "Enso OS"} - - {id: 2043, cat: PC/ISO, desc: "Exe GNU/Linux"} - - {id: 2045, cat: PC/ISO, desc: "Extix"} - - {id: 2220, cat: PC/ISO, desc: "Fatdog64 Linux"} - - {id: 178, cat: PC/ISO, desc: "Fedora"} - - {id: 2194, cat: PC/ISO, desc: "Feren OS"} - - {id: 181, cat: PC/ISO, desc: "Finnix"} - - {id: 2266, cat: PC/ISO, desc: "Forensic Hard Copy"} - - {id: 190, cat: PC/ISO, desc: "FreeBSD"} - - {id: 194, cat: PC/ISO, desc: "FreeNAS"} - - {id: 197, cat: PC/ISO, desc: "Freespire"} - - {id: 2046, cat: PC/ISO, desc: "FuguIta"} - - {id: 2290, cat: PC/ISO, desc: "FuryBSD"} - - {id: 2256, cat: PC/ISO, desc: "G4L"} - - {id: 2235, cat: PC/ISO, desc: "GALPon MiniNo"} - - {id: 1212, cat: PC/ISO, desc: "GParted"} - - {id: 2299, cat: PC/ISO, desc: "Garuda Linux"} - - {id: 2214, cat: PC/ISO, desc: "GeckoLinux"} - - {id: 203, cat: PC/ISO, desc: "GeeXboX"} - - {id: 206, cat: PC/ISO, desc: "Gentoo"} - - {id: 2048, cat: PC/ISO, desc: "GhostBSD"} - - {id: 210, cat: PC/ISO, desc: "Gnewsense"} - - {id: 216, cat: PC/ISO, desc: "GoboLinux"} - - {id: 220, cat: PC/ISO, desc: "Greenie Linux"} - - {id: 221, cat: PC/ISO, desc: "Grml"} - - {id: 2280, cat: PC/ISO, desc: "GroovyArcade"} - - {id: 2228, cat: PC/ISO, desc: "Guix System"} - - {id: 2049, cat: PC/ISO, desc: "Haiku"} - - {id: 2276, cat: PC/ISO, desc: "Hanthana Linux"} - - {id: 2186, cat: PC/ISO, desc: "HardenedBSD"} - - {id: 2200, cat: PC/ISO, desc: "Heads"} - - {id: 2293, cat: PC/ISO, desc: "Hiren's BootCD+PE"} - - {id: 2166, cat: PC/ISO, desc: "Huayra Linux"} - - {id: 2244, cat: PC/ISO, desc: "Hyperbola GNU"} - - {id: 252, cat: PC/ISO, desc: "IPFire"} - - {id: 2181, cat: PC/ISO, desc: "KDE Neon"} - - {id: 2052, cat: PC/ISO, desc: "KaOS"} - - {id: 2294, cat: PC/ISO, desc: "Kaisen Linux"} - - {id: 2051, cat: PC/ISO, desc: "Kali"} - - {id: 2053, cat: PC/ISO, desc: "Karoshi"} - - {id: 277, cat: PC/ISO, desc: "Knoppix"} - - {id: 2054, cat: PC/ISO, desc: "Kolibri"} - - {id: 287, cat: PC/ISO, desc: "Kubuntu"} - - {id: 2057, cat: PC/ISO, desc: "Kwort Linux"} - - {id: 2068, cat: PC/ISO, desc: "LXLE"} - - {id: 2258, cat: PC/ISO, desc: "Lakka"} - - {id: 2176, cat: PC/ISO, desc: "LibreELEC"} - - {id: 2061, cat: PC/ISO, desc: "LinHES"} - - {id: 2242, cat: PC/ISO, desc: "Linspire"} - - {id: 311, cat: PC/ISO, desc: "Linux"} - - {id: 2239, cat: PC/ISO, desc: "Linux Kodachi"} - - {id: 1234, cat: PC/ISO, desc: "Linux Lite"} - - {id: 347, cat: PC/ISO, desc: "Linux Mint"} - - {id: 304, cat: PC/ISO, desc: "LinuxConsole"} - - {id: 2063, cat: PC/ISO, desc: "Linuxfx"} - - {id: 2064, cat: PC/ISO, desc: "Liquid Lemur"} - - {id: 2191, cat: PC/ISO, desc: "Live Raizo"} - - {id: 1218, cat: PC/ISO, desc: "Lubuntu"} - - {id: 2163, cat: PC/ISO, desc: "MOFO Linux"} - - {id: 2177, cat: PC/ISO, desc: "MX Linux"} - - {id: 2296, cat: PC/ISO, desc: "Mabox Linux"} - - {id: 1228, cat: PC/ISO, desc: "Mageia"} - - {id: 2071, cat: PC/ISO, desc: "MakuluLinux"} - - {id: 2073, cat: PC/ISO, desc: "Manjaro"} - - {id: 343, cat: PC/ISO, desc: "MidnightBSD"} - - {id: 2075, cat: PC/ISO, desc: "Minino"} - - {id: 2076, cat: PC/ISO, desc: "Minix"} - - {id: 2300, cat: PC/ISO, desc: "MocaccinoOS"} - - {id: 2241, cat: PC/ISO, desc: "MorpheusArch Linux"} - - {id: 373, cat: PC/ISO, desc: "Mythbuntu"} - - {id: 2078, cat: PC/ISO, desc: "NAS4Free"} - - {id: 2302, cat: PC/ISO, desc: "NakeDeb"} - - {id: 2217, cat: PC/ISO, desc: "Namib GNU/Linux"} - - {id: 2079, cat: PC/ISO, desc: "Neptune"} - - {id: 2080, cat: PC/ISO, desc: "Nethserver"} - - {id: 2081, cat: PC/ISO, desc: "Netrunner"} - - {id: 2190, cat: PC/ISO, desc: "Nitrux"} - - {id: 2084, cat: PC/ISO, desc: "NixOS"} - - {id: 2254, cat: PC/ISO, desc: "NomadBSD"} - - {id: 391, cat: PC/ISO, desc: "Nst"} - - {id: 2086, cat: PC/ISO, desc: "Nutyx"} - - {id: 2203, cat: PC/ISO, desc: "OPNsense"} - - {id: 2234, cat: PC/ISO, desc: "OSGeoLive"} - - {id: 2096, cat: PC/ISO, desc: "OSMC"} - - {id: 2240, cat: PC/ISO, desc: "Obarun"} - - {id: 2211, cat: PC/ISO, desc: "Omarine"} - - {id: 401, cat: PC/ISO, desc: "OpenBSD"} - - {id: 2090, cat: PC/ISO, desc: "OpenIndiana"} - - {id: 2091, cat: PC/ISO, desc: "OpenMandriva Lx"} - - {id: 2092, cat: PC/ISO, desc: "Openmediavault"} - - {id: 2093, cat: PC/ISO, desc: "Opensuse"} - - {id: 1237, cat: PC/ISO, desc: "Oracle Linux"} - - {id: 4, cat: PC/ISO, desc: "Other distros"} - - {id: 2218, cat: PC/ISO, desc: "OviOS Linux"} - - {id: 423, cat: PC/ISO, desc: "PCLinuxOS"} - - {id: 2233, cat: PC/ISO, desc: "PI MusicBox"} - - {id: 416, cat: PC/ISO, desc: "Paldo"} - - {id: 2098, cat: PC/ISO, desc: "Parabola GNU/Linux"} - - {id: 419, cat: PC/ISO, desc: "Pardus"} - - {id: 2162, cat: PC/ISO, desc: "Parrot"} - - {id: 422, cat: PC/ISO, desc: "Pcbsd"} - - {id: 430, cat: PC/ISO, desc: "Peppermint"} - - {id: 432, cat: PC/ISO, desc: "Pfsense"} - - {id: 2101, cat: PC/ISO, desc: "Pinguy OS"} - - {id: 2102, cat: PC/ISO, desc: "Pisi Linux"} - - {id: 441, cat: PC/ISO, desc: "Plamo Linux"} - - {id: 2103, cat: PC/ISO, desc: "Plop"} - - {id: 1235, cat: PC/ISO, desc: "Point Linux"} - - {id: 2207, cat: PC/ISO, desc: "Pop!_OS"} - - {id: 2106, cat: PC/ISO, desc: "Porteus"} - - {id: 2107, cat: PC/ISO, desc: "Porteus Kiosk"} - - {id: 2209, cat: PC/ISO, desc: "PrimTux"} - - {id: 2247, cat: PC/ISO, desc: "Project Trident"} - - {id: 1230, cat: PC/ISO, desc: "Proxmox"} - - {id: 452, cat: PC/ISO, desc: "Puppy"} - - {id: 2221, cat: PC/ISO, desc: "PureOS"} - - {id: 2108, cat: PC/ISO, desc: "Q4OS"} - - {id: 2109, cat: PC/ISO, desc: "Qubes"} - - {id: 455, cat: PC/ISO, desc: "Quirky"} - - {id: 2180, cat: PC/ISO, desc: "RancherOS"} - - {id: 2201, cat: PC/ISO, desc: "Raspberry Digital"} - - {id: 2199, cat: PC/ISO, desc: "Raspberry Slideshow"} - - {id: 2110, cat: PC/ISO, desc: "Raspbian"} - - {id: 2111, cat: PC/ISO, desc: "Reactos"} - - {id: 2232, cat: PC/ISO, desc: "RebeccaBlackOS"} - - {id: 2236, cat: PC/ISO, desc: "Reborn OS"} - - {id: 2274, cat: PC/ISO, desc: "Redcore Linux"} - - {id: 458, cat: PC/ISO, desc: "Redhat"} - - {id: 2202, cat: PC/ISO, desc: "Refracta"} - - {id: 2291, cat: PC/ISO, desc: "RegataOS"} - - {id: 2114, cat: PC/ISO, desc: "Rescatux"} - - {id: 2270, cat: PC/ISO, desc: "Resilient Linux"} - - {id: 2116, cat: PC/ISO, desc: "Robolinux"} - - {id: 1223, cat: PC/ISO, desc: "Rosa"} - - {id: 2117, cat: PC/ISO, desc: "Runtu"} - - {id: 2119, cat: PC/ISO, desc: "SELKS"} - - {id: 14, cat: PC/ISO, desc: "Sabayon"} - - {id: 2118, cat: PC/ISO, desc: "Salentos"} - - {id: 477, cat: PC/ISO, desc: "Salix"} - - {id: 482, cat: PC/ISO, desc: "Scientific Linux"} - - {id: 2304, cat: PC/ISO, desc: "Security Onion"} - - {id: 2250, cat: PC/ISO, desc: "Septor"} - - {id: 489, cat: PC/ISO, desc: "SharkLinux"} - - {id: 2121, cat: PC/ISO, desc: "Siduction"} - - {id: 2122, cat: PC/ISO, desc: "Simplicity Linux"} - - {id: 2164, cat: PC/ISO, desc: "Skywave Linux"} - - {id: 493, cat: PC/ISO, desc: "Slackel"} - - {id: 495, cat: PC/ISO, desc: "Slackware"} - - {id: 499, cat: PC/ISO, desc: "Slax"} - - {id: 2303, cat: PC/ISO, desc: "Slimbook OS"} - - {id: 2124, cat: PC/ISO, desc: "SmartOS"} - - {id: 2125, cat: PC/ISO, desc: "Smeserver"} - - {id: 505, cat: PC/ISO, desc: "Smoothwall Express"} - - {id: 2126, cat: PC/ISO, desc: "Solus"} - - {id: 2127, cat: PC/ISO, desc: "Solydxk"} - - {id: 1236, cat: PC/ISO, desc: "SparkyLinux"} - - {id: 2297, cat: PC/ISO, desc: "SpiralLinux"} - - {id: 2225, cat: PC/ISO, desc: "Star"} - - {id: 2132, cat: PC/ISO, desc: "Steamos"} - - {id: 2174, cat: PC/ISO, desc: "Super Grub2 Disk"} - - {id: 2134, cat: PC/ISO, desc: "SuperX"} - - {id: 2189, cat: PC/ISO, desc: "SwagArch"} - - {id: 2267, cat: PC/ISO, desc: "Symphony OS"} - - {id: 2295, cat: PC/ISO, desc: "SysLinuxOS"} - - {id: 2135, cat: PC/ISO, desc: "SystemRescueCD"} - - {id: 2188, cat: PC/ISO, desc: "TENS"} - - {id: 2136, cat: PC/ISO, desc: "Tails"} - - {id: 542, cat: PC/ISO, desc: "Thinstation"} - - {id: 2279, cat: PC/ISO, desc: "TinyPaw Linux"} - - {id: 2138, cat: PC/ISO, desc: "Tinycore"} - - {id: 550, cat: PC/ISO, desc: "Trisquel"} - - {id: 2195, cat: PC/ISO, desc: "TrueOS"} - - {id: 2271, cat: PC/ISO, desc: "Tsurugi Linux"} - - {id: 2140, cat: PC/ISO, desc: "TurnKey"} - - {id: 2206, cat: PC/ISO, desc: "UBOS"} - - {id: 563, cat: PC/ISO, desc: "Ubuntu"} - - {id: 2198, cat: PC/ISO, desc: "Ubuntu Budgie"} - - {id: 2284, cat: PC/ISO, desc: "Ubuntu Cinnamon Rem"} - - {id: 2249, cat: PC/ISO, desc: "Ubuntu DesktopPack"} - - {id: 2143, cat: PC/ISO, desc: "Ubuntu Gnome"} - - {id: 2144, cat: PC/ISO, desc: "Ubuntu Kylin"} - - {id: 2145, cat: PC/ISO, desc: "Ubuntu Mate"} - - {id: 564, cat: PC/ISO, desc: "Ubuntu Studio"} - - {id: 569, cat: PC/ISO, desc: "Ultimate Edition"} - - {id: 2222, cat: PC/ISO, desc: "Univention Corporate"} - - {id: 2149, cat: PC/ISO, desc: "Untangle NG Firewall"} - - {id: 2173, cat: PC/ISO, desc: "Uruk GNU/Linux"} - - {id: 579, cat: PC/ISO, desc: "Vector"} - - {id: 2282, cat: PC/ISO, desc: "Virage"} - - {id: 2151, cat: PC/ISO, desc: "Void Linux"} - - {id: 2152, cat: PC/ISO, desc: "Volumio"} - - {id: 2153, cat: PC/ISO, desc: "Voyager Live"} - - {id: 2154, cat: PC/ISO, desc: "VyOS"} - - {id: 2281, cat: PC/ISO, desc: "Web Security Dojo"} - - {id: 2246, cat: PC/ISO, desc: "Whonix"} - - {id: 2155, cat: PC/ISO, desc: "Wifislax"} - - {id: 2229, cat: PC/ISO, desc: "XiVa Studio"} - - {id: 2251, cat: PC/ISO, desc: "XigmaNAS"} - - {id: 607, cat: PC/ISO, desc: "Xubuntu"} - - {id: 612, cat: PC/ISO, desc: "Zen"} - - {id: 2159, cat: PC/ISO, desc: "Zentyal"} - - {id: 346, cat: PC/ISO, desc: "Zenwalk Linux"} - - {id: 2160, cat: PC/ISO, desc: "Zeroshell"} - - {id: 2193, cat: PC/ISO, desc: "Zevenet"} - - {id: 1233, cat: PC/ISO, desc: "Zorin"} - # undocumented categories - - {id: 531, cat: PC/ISO, desc: "no category"} - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -search: - paths: - # https://linuxtracker.org/index.php?page=torrents&search=&category=0&active=0 - - path: index.php - - path: index.php - inputs: - pages: 2 - inputs: - $raw: "category={{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}&" - search: "{{ .Keywords }}" - page: torrents - active: 1 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.lista[width="100%"] > tbody > tr:has(a[href^="index.php?page=torrent-details&id="][title]) - - fields: - category_id: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - category: - text: "{{ .Result.category_id }}" - title: - selector: a[href^="index.php?page=torrent-details&id="][title] - details: - selector: a[href^="index.php?page=torrent-details&id="][title] - attribute: href - infohash: - selector: a[href^="index.php?page=torrent-details&id="][title] - attribute: href - filters: - - name: querystring - args: id - poster: - selector: img[src*="/images/categories/"] - attribute: src - date: - selector: td:nth-child(2) > table > tbody > tr > td - remove: strong - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "dd/MM/yyyy zzz" - size: - selector: td:nth-child(2) > table > tbody > tr:nth-child(2) > td - remove: strong - seeders: - selector: td:nth-child(2) > table > tbody > tr:nth-child(3) > td - remove: strong - leechers: - selector: td:nth-child(2) > table > tbody > tr:nth-child(4) > td - remove: strong - grabs: - selector: td:nth-child(2) > table > tbody > tr:nth-child(5) > td - remove: strong - description: - selector: td:nth-child(2) - remove: table - filters: - - name: prepend - args: "category={{ .Result.category_id }}; " - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# xbtit 2.3.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/locadora.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/locadora.yml deleted file mode 100644 index fb434aa7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/locadora.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: locadora -name: Locadora (API) -description: "Locadora is a BRAZILIAN Private Torrent Tracker for MOVIES / TV / ANIME" -language: pt-BR -type: private -encoding: UTF-8 -links: - - https://locadora.cc/ -legacylinks: - - https://locadora.xyz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmes"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 6, cat: TV/Anime, desc: "Animes"} - - {id: 7, cat: Audio/Video, desc: "Shows"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Locadora account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts that are inactive for 90 days are deactivated." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Cinema TV)", "Cinema_TV"] - - name: re_replace - args: ["(?i)(Ficção científica)", "Ficção_científica"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - 0: "{{ .False }}" - 1: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 8.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/longpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/longpt.yml deleted file mode 100644 index 2d326d98..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/longpt.yml +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: longpt -name: LongPT -description: "LongPT is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://longpt.org/ - -caps: - categorymappings: - - {id: 408, cat: Audio/Lossless, desc: "音频"} - - {id: 410, cat: Audio/Audiobook, desc: "有声书"} - - {id: 409, cat: Other, desc: "其他"} - - {id: 407, cat: TV/Sport, desc: "体育"} - - {id: 406, cat: Audio/Video, desc: "音乐视频"} - - {id: 403, cat: TV, desc: "综艺"} - - {id: 402, cat: TV, desc: "剧集"} - - {id: 405, cat: TV/Anime, desc: "动画"} - - {id: 404, cat: TV/Documentary, desc: "纪录片"} - - {id: 401, cat: Movies, desc: "电影"} - - {id: 411, cat: Other, desc: "其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the LongPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description_verified: - case: - span[title="通过"]: "Verified:" - span[title="未审"]: "Unverified:" - span[title="拒绝"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/losslessclub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/losslessclub.yml deleted file mode 100644 index a2d8d3ad..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/losslessclub.yml +++ /dev/null @@ -1,100 +0,0 @@ ---- -id: losslessclub -name: LosslessClub -description: "LosslessClub is a RUSSIAN Private Torrent Tracker for High Quality Music" -language: ru-RU -type: private -encoding: UTF-8 -links: - - https://losslessclub.com/ - -caps: - categories: - 1: Audio/Lossless - - modes: - search: [q] - music-search: [q, artist] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_login - type: info - label: About LosslessClub Forum versus LosslessClub Tracker - default: "The LosslessClub Forum credentials are different from the LosslessClub Tracker credentials so if the indexer is giving you 'Error Username or password is incorrect' then you are likely using the Forum credentials to access the Tracker." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded > div.error - test: - path: browse.php - selector: span.bar_user_welcome - -search: - paths: - - path: browse.php - inputs: - search: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ .Keywords }}{{ end }}" - t: all - - rows: - selector: div#releases-table > table > tbody > tr:has(a.browselink) - - fields: - category: - text: 1 - title: - selector: a.browselink - details: - selector: a.browselink - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img.thumbnail - attribute: src - size: - selector: td:nth-child(5) - grabs: - optional: true - selector: td:nth-child(6) br + span - seeders: - selector: td:nth-child(6) - remove: br + span - filters: - - name: regexp - args: "([\\.\\d]+)\\s+\\|" - leechers: - selector: td:nth-child(6) - remove: br + span - filters: - - name: regexp - args: "\\|\\s*([\\.\\d]+)" - date: - selector: td:nth-child(7) - remove: a, i - filters: - - name: replace - args: ["by", ""] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "d/MM/yy zzz" - description: - selector: div.tag_list_browse - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# Кinokрk releaser v. 2.40.473 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lp-bits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lp-bits.yml deleted file mode 100644 index 1572a3ec..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lp-bits.yml +++ /dev/null @@ -1,162 +0,0 @@ ---- -id: lp-bits -name: LP-Bits 2.0 -description: LP-Bits 2.0 is a Private Torrent Tracker for Linkin Park Music Releases." -language: en-US -type: private -encoding: UTF-8 -links: - - https://lp-bits.com/ - -caps: - categorymappings: - - {id: 409, cat: Audio/Video, desc: "Video Audience Shot"} - - {id: 404, cat: Audio/Video, desc: "Documentaries \ Misc"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - {id: 407, cat: Audio, desc: "Audio Professional"} - - {id: 408, cat: Audio/Video, desc: "Pro Shot Video"} - - {id: 410, cat: Audio, desc: "Audio Audience Shot"} - - modes: - search: [q] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[title][href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - filters: - - name: replace - args: ["pic/imdb_pic/nophoto.gif", ""] - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 -# NexusPHP v1.9.14 2026-01-23 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lst.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lst.yml deleted file mode 100644 index 114de0d8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/lst.yml +++ /dev/null @@ -1,201 +0,0 @@ ---- -id: lst -name: LST -description: "LST is an ENGLISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://lst.gg/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 6, cat: TV/Anime, desc: "Anime"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: Console, desc: "Game"} - - {id: 5, cat: PC, desc: "Application"} - - {id: 8, cat: XXX, desc: "XXX"} - - {id: 9, cat: Books/EBook, desc: "Ebook/Manga"} - - {id: 10, cat: Other, desc: "Education"} - - {id: 11, cat: Movies/Other, desc: "FANRES"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your LST account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts get disabled after 90 days of no logins or any sort of activity. Accounts will also get soft deleted after 120 days." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 1.0 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/luckpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/luckpt.yml deleted file mode 100644 index 7e8bb158..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/luckpt.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: luckpt -name: LuckPT -description: "LuckPT is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.luckpt.de/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影 / Movies"} - - {id: 402, cat: TV, desc: "电视剧 / TV Series"} - - {id: 405, cat: TV/Anime, desc: "动漫 / Animations"} - - {id: 406, cat: Audio/Video, desc: "演唱会、MV / Music Videos"} - - {id: 408, cat: Audio, desc: "音乐 / Music"} - - {id: 409, cat: Other, desc: "其他 / Other"} - - {id: 410, cat: TV, desc: "综艺 / TV Shows"} - - {id: 411, cat: TV/Documentary, desc: "纪录片 / Documentary"} - - {id: 412, cat: TV/Sport, desc: "体育 / Sports"} - - {id: 413, cat: TV/Other, desc: "短剧 / Playlet"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. If there is no valid traffic within 100 days of registration (participation in the calculation of sharing rate), the account will be blocked
  2. After the level is reduced to \"Peasant\", if the sharing rate is not improved within 30 days, the account will be blocked
  3. Long-term non-login:
      - UnParked account: 150 consecutive days of non-login → banned
      - Parked account: 400 consecutive days of non-login → banned
      - Veteran Master and above levels are not subject to long-term non-login ban restrictions
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/luminarr-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/luminarr-api.yml deleted file mode 100644 index ffe28f67..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/luminarr-api.yml +++ /dev/null @@ -1,190 +0,0 @@ ---- -id: luminarr-api -name: Luminarr (API) -description: "Luminarr is a Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://luminarr.me/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your luminarr.me account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "
  • To avoid your account being disabled, you must login at least once every 3 months (90 days). Accounts will be pruned three months after deactivation for inactivity.
  • Luminarr is a sharing community, not a collection service. You must actively participate by uploading or downloading content within 30 days of account creation. Accounts that show no activity within this period may be pruned.
" - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.6 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.6 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mactorrentsdownload.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mactorrentsdownload.yml deleted file mode 100644 index 0c23d038..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mactorrentsdownload.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: mactorrentsdownload -name: Mac Torrents Download -description: "Mac Torrents Download is a Public tracker for Mac software" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.torrentmac.net/ - -caps: - categorymappings: - - {id: "apps", cat: PC/Mac, desc: "Apps"} - - {id: "games", cat: PC/Games, desc: Games} - - {id: "design-assets", cat: Other, desc: Design Assets} - - {id: "uncategorized", cat: Other, desc: Uncategorized} - - modes: - search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - before: - pathselector: - selector: a[href^="/download?"] - attribute: href - filters: - - name: replace - args: ["/download?", "/download/?"] - selectors: - - selector: script:contains("torrentLink") - usebeforeresponse: true - filters: - - name: regexp - args: "torrentLink = '(.+?)';" - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - - rows: - selector: article[id^="post-"] - filters: - - name: andmatch - - fields: - category: - selector: a[href*="/category/"] - attribute: href - filters: - - name: split - args: ["/", 4] - title: - selector: a - attribute: title - details: - selector: a - attribute: href - download: - selector: a - attribute: href - poster: - selector: img - attribute: src - description: - selector: section p - date: - selector: time - attribute: datetime - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-dd zzz" - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# WordPress 4.9.18 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/madsrevolution.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/madsrevolution.yml deleted file mode 100644 index 8674197e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/madsrevolution.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: madsrevolution -name: MaDs Revolution -description: "MaDs Revolution is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://madsrevolution.net/ - -caps: - categorymappings: - - {id: 52, cat: Movies/UHD, desc: "Filme UHD"} - - {id: 3, cat: Movies/HD, desc: "Filme 1080p"} - - {id: 4, cat: Movies/HD, desc: "Filme 720p"} - - {id: 47, cat: Movies/HD, desc: "Filme x264"} - - {id: 2, cat: Movies/SD, desc: "Filme DivX/XviD"} - - {id: 1, cat: Movies/DVD, desc: "Filme DVD-R"} - - {id: 20, cat: Movies/3D, desc: "Filme 3D"} - - {id: 54, cat: TV/UHD, desc: "Serien UHD"} - - {id: 28, cat: TV/HD, desc: "Serien 1080p"} - - {id: 26, cat: TV/HD, desc: "Serien 720p"} - - {id: 48, cat: TV/HD, desc: "Serien x264"} - - {id: 27, cat: TV/SD, desc: "Serien DivX/XviD"} - - {id: 25, cat: TV/SD, desc: "Serien DVD-R"} - - {id: 8, cat: TV/Other, desc: "Serien Unsorted"} - - {id: 38, cat: Audio, desc: "Musik Album"} - - {id: 37, cat: Audio, desc: "Musik Single/EP"} - - {id: 40, cat: Audio/Video, desc: "Musik Video"} - - {id: 39, cat: Audio, desc: "Musik Pack"} - - {id: 14, cat: Audio/Other, desc: "Musik Unsorted"} - - {id: 44, cat: PC/0day, desc: "Software Windows"} - - {id: 42, cat: PC/Mac, desc: "Software Mac"} - - {id: 41, cat: PC, desc: "Software Linux"} - - {id: 43, cat: PC/Mobile-Other, desc: "Software Mobile"} - - {id: 11, cat: PC, desc: "Software Unsorted"} - - {id: 12, cat: PC/Games, desc: "Spiele PC"} - - {id: 13, cat: Console, desc: "Spiele Konsole"} - - {id: 17, cat: Console, desc: "Spiele Portable"} - - {id: 46, cat: Console, desc: "Spiele Other"} - - {id: 53, cat: TV/Documentary, desc: "Dokus UHD"} - - {id: 24, cat: TV/Documentary, desc: "Dokus 1080p"} - - {id: 22, cat: TV/Documentary, desc: "Dokus 720p"} - - {id: 49, cat: TV/Documentary, desc: "Dokus x264"} - - {id: 23, cat: TV/Documentary, desc: "Dokus DivX/XviD"} - - {id: 21, cat: TV/Documentary, desc: "Dokus DVD-R"} - - {id: 7, cat: TV/Documentary, desc: "Dokus Unsorted"} - - {id: 55, cat: TV/Sport, desc: "Sport UHD"} - - {id: 32, cat: TV/Sport, desc: "Sport 1080p"} - - {id: 30, cat: TV/Sport, desc: "Sport 720p"} - - {id: 50, cat: TV/Sport, desc: "Sport x264"} - - {id: 31, cat: TV/Sport, desc: "Sport DivX/XviD"} - - {id: 29, cat: TV/Sport, desc: "Sport DVD-R"} - - {id: 9, cat: TV/Sport, desc: "Sport Unsorted"} - - {id: 15, cat: Audio/Audiobook, desc: "Books aBooks"} - - {id: 18, cat: Books/EBook, desc: "Books eBooks"} - - {id: 56, cat: XXX, desc: "XXX UHD"} - - {id: 36, cat: XXX, desc: "XXX 1080p"} - - {id: 34, cat: XXX, desc: "XXX 720p"} - - {id: 51, cat: XXX, desc: "XXX x264"} - - {id: 35, cat: XXX, desc: "XXX DivX/XviD"} - - {id: 33, cat: XXX, desc: "XXX DVD-R"} - - {id: 10, cat: XXX, desc: "XXX Unsorted"} - - {id: 5, cat: TV/Anime, desc: "Misc Anime"} - - {id: 6, cat: XXX, desc: "Misc Hentai"} - - {id: 19, cat: Other, desc: "Misc Other"} - - modes: - search: [q] - tv-search: [q, season, ep, genre] - movie-search: [q, genre] - music-search: [q, genre] - book-search: [q, genre] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: s3 - options: - s3: created - s6: seeders - s4: size - s1: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_activity - type: info - label: Account Inactivity - default: "After 8 weeks of inactivity, the account will be automatically deleted." - -login: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: div#error-text - test: - path: index.php - selector: a[href^="logout.php?auth="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - action: "{{ if .Config.freeleech }}uploadonly{{ else }}{{ end }}" - searchstr: "{{ .Keywords }}" - searchtags: "{{ .Query.Genre }}" - # 0 any, 1 all - tags_type: 0 - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - # site does not support imdbid searching or present imdb links in results. - - rows: - selector: table#torrent_table > tbody > tr.torrent:has(a[href^="torrents.php?action=download&id="]) - - fields: - category: - selector: a.c-sub - attribute: href - filters: - - name: regexp - args: "\\[(\\d+)\\]" - title: - selector: a[href^="torrents.php?id="] - details: - selector: a[href^="torrents.php?id="] - attribute: href - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - poster: - selector: a[href^="torrents.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - genre: - selector: div.tags - description: - text: "{{ .Result.genre }}" - date: - selector: td:nth-last-child(5) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd MMM yyyy, HH:mm zzz" - size: - selector: td:nth-last-child(4) - grabs: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - downloadvolumefactor: - case: - "strong:contains(\"UploadOnly!\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.8 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magico.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magico.yml deleted file mode 100644 index d01c6b6f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magico.yml +++ /dev/null @@ -1,329 +0,0 @@ ---- -id: magico -replaces: - - trellas -name: Magico -description: "Magico (Trellas) is a GREEK Private Torrent Tracker for MOVIES / TV / GENERAL" -language: el-GR -type: private -encoding: UTF-8 -links: - - https://mpellas.me/ -legacylinks: - - https://magico.to/ - - https://magico.info/ - - https://magico.one/ - - https://magico.club/ - - https://magico.fun/ - - https://trellas.info/ - - https://trelas.club/ - - https://trellas.net/ - - https://trellas.me/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 84, cat: Movies, desc: "Movies New (2022, 2023, 2024)"} - - {id: 14, cat: Movies/SD, desc: "Movies DVDRip-WEBRip-BRRip (up to 2021)"} - - {id: 16, cat: Movies/SD, desc: "Movies (CAM)"} - - {id: 17, cat: Movies/BluRay, desc: "Movies BLU-RAY"} - - {id: 44, cat: Movies, desc: "Movies Greek"} - - {id: 76, cat: Movies, desc: "Movies Documentaries"} - - {id: 82, cat: Movies, desc: "Movies Theater"} - - - {id: 52, cat: TV, desc: "TV Series"} - - {id: 53, cat: TV/SD, desc: "TV Series Foreign"} - - {id: 70, cat: TV/HD, desc: "TV Series Foreign (720p/1080p)"} - - {id: 54, cat: TV, desc: "TV Series Greek"} - - {id: 86, cat: TV, desc: "TV Series Foreign Complete Seasons"} - - {id: 92, cat: TV, desc: "TV Series Foreign Complete Seasons (720p/1080p)"} - - {id: 87, cat: TV, desc: "TV Series Greek Complete Seasons"} - - {id: 99, cat: TV, desc: "TV Series Greek Turkish women"} - - {id: 63, cat: TV, desc: "TV Series Recreational"} - - {id: 83, cat: TV/Sport, desc: "TV Sports"} - - - {id: 55, cat: Movies, desc: "Children"} - - {id: 38, cat: Movies, desc: "Children (Movies)"} - - {id: 39, cat: Movies, desc: "Children Subtitled (Movies)"} - - {id: 93, cat: Movies, desc: "Children (TV Series)"} - - {id: 94, cat: Movies, desc: "Children Subtitled (TV Series)"} - - {id: 45, cat: Movies, desc: "Children Anime"} - - {id: 98, cat: Movies, desc: "Children 3D"} - - {id: 64, cat: Movies, desc: "Children Music"} - - - {id: 7, cat: Audio, desc: "Music Foreign"} - - {id: 21, cat: Audio, desc: "Music Foreign New Releases"} - - {id: 46, cat: Audio, desc: "Music Foreign Old Releases"} - - {id: 32, cat: Audio, desc: "Music Foreign Discographies"} - - {id: 20, cat: Audio, desc: "Music Foreign Collections"} - - {id: 102, cat: Audio, desc: "Music Foreign MAGICO Radio"} - - {id: 108, cat: Audio, desc: "Music Foreign Lice Cd's"} - - {id: 81, cat: Audio, desc: "Music Foreign Karaoke"} - - {id: 95, cat: Audio, desc: "Music Foreign Soundtrack"} - - - {id: 47, cat: Audio, desc: "Music Greek"} - - {id: 48, cat: Audio, desc: "Music Greek New Releases"} - - {id: 49, cat: Audio, desc: "Music Greek Old Releases"} - - {id: 51, cat: Audio, desc: "Music Greek Discographies"} - - {id: 50, cat: Audio, desc: "Music Greek Collections"} - - {id: 101, cat: Audio, desc: "Music Greek MAGICO Radio"} - - {id: 97, cat: Audio, desc: "Music Greek Live Cd's"} - - {id: 57, cat: Audio, desc: "Music Greek Rebetika"} - - {id: 58, cat: Audio, desc: "Music Greek Primary"} - - {id: 59, cat: Audio, desc: "Music Greek Cretan"} - - {id: 60, cat: Audio, desc: "Music Greek Island"} - - {id: 62, cat: Audio, desc: "Music Greek Pontian"} - - {id: 80, cat: Audio, desc: "Music Greek Karaoke"} - - {id: 96, cat: Audio, desc: "Music Greek Soundtracks"} - - - {id: 73, cat: Audio, desc: "Music Seasonal"} - - {id: 74, cat: Audio, desc: "Music Seasonal Xmas"} - - {id: 79, cat: Audio, desc: "Music Seasonal Easter"} - - {id: 75, cat: Audio, desc: "Music Seasonal Halloween"} - - {id: 78, cat: Audio, desc: "Music Seasonal Valentines"} - - - {id: 33, cat: Books, desc: "Books"} - - {id: 41, cat: Books, desc: "Books Greek"} - - {id: 71, cat: Books, desc: "Books (A)"} - - {id: 65, cat: Books, desc: "Books Comics"} - - {id: 43, cat: Books, desc: "Books Periodicals"} - - {id: 105, cat: Books, desc: "Books Audio"} - - {id: 72, cat: Books, desc: "Books Children"} - - {id: 66, cat: Books, desc: "Books Educational"} - - {id: 42, cat: Books, desc: "Books Foreign"} - - {id: 67, cat: Books, desc: "Books Photos"} - - - {id: 6, cat: PC, desc: "Programs"} - - {id: 18, cat: PC/0day, desc: "Programs PC"} - - {id: 40, cat: PC/Mobile-Android, desc: "Programs Android"} - - {id: 19, cat: PC/Mac, desc: "Programs Mac"} - - - {id: 2, cat: PC/Games, desc: "Games"} - - {id: 27, cat: PC/Games, desc: "Games PC"} - - - {id: 88, cat: Other, desc: "Tutorials / Design / Accessories"} - - {id: 89, cat: Other, desc: "Tutorials Photoshop"} - - {id: 91, cat: Other, desc: "Tutorials Illustrator"} - - {id: 90, cat: Other, desc: "Tutorials After Effects"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: ?p=home&pid=1 - method: form - form: form#loginbox_form - submitpath: /ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: ":contains(\"-ERROR-\")" - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - - path: /?p=torrents&pid=10 - method: post - keywordsfilters: - - name: toupper - - name: re_replace # add brackets to year on searchquery if missing - args: ["\\(*((19|20)\\d{2})\\)*", "($1)"] - - name: replace # use this as a workaround till #893 is implemented - args: ["Greys Anatomy", "Grey's Anatomy"] - - name: re_replace - args: ["[^\\w\\d\\(\\)]+", "+"] - inputs: - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - error: - - selector: div#show_error:not(:contains("Δεν βρέθηκαν αποτελέσματα")) font - - rows: - selector: "div#content > div.torrent-box[id^=\"torrent_\"]{{ if .Config.freeleech }}:has(img[src$=\"torrent_free.png\"]){{ else }}{{ end }}, tr:has(a[href*=\"?p=torrents\"][href*=\"&action=details\"]){{ if .Config.freeleech }}:has(img[src$=\"torrent_free.png\"]){{ else }}{{ end }}" - - fields: - title: - selector: a[href*="?p=torrents"][href*="&action=details"] - filters: - - name: replace - args: ["Grey's Anatomy", "Greys Anatomy"] - category: - selector: a[href*="?p=torrents"][href*="&cid="] - attribute: href - filters: - - name: querystring - args: cid - details: - selector: a[href*="?p=torrents"][href*="&action=details"] - attribute: href - download: - selector: a[href*="?p=torrents"][href*="&action=download"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: a[rel="torrent_size"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - grabs: - selector: a[rel="times_completed"] - poster: - selector: a[rel="fancybox"] - attribute: href - description: - selector: div.torrentDetails > div:has(span.floatright) - filters: - - name: re_replace - args: ["^(.+?\\/10)", "IMDB Rating: $1"] - genre: - selector: div.torrentDetails > div:has(span.floatright) - remove: span - filters: - - name: re_replace - args: ["(Είδος:|\\))", ""] - - name: replace - args: [" (", "_"] - - name: replace - args: ["Α- ", "Α-_"] - - name: replace - args: ["Ω- ", "Ω-_"] - - name: replace - args: [" - ", "_-_"] - downloadvolumefactor: - case: - "img[src$=\"torrent_free.png\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "*": 1 - date_day: - # Uploaded Yesterday at 20:12 by - selector: div.torrentOwner:not(:has(abbr.timeago)):contains("day"), div.torrentOwner:not(:has(abbr.timeago)):contains("σήμερα"), div.torrentOwner:not(:has(abbr.timeago)):contains("εχθές"), div.torrentOwner:not(:has(abbr.timeago)):contains("Δευτέρα"), div.torrentOwner:not(:has(abbr.timeago)):contains("Τρίτη"), div.torrentOwner:not(:has(abbr.timeago)):contains("Τετάρτη"), div.torrentOwner:not(:has(abbr.timeago)):contains("Πέμπτη"), div.torrentOwner:not(:has(abbr.timeago)):contains("Παρασκευή"), div.torrentOwner:not(:has(abbr.timeago)):contains("Σάββατο"), div.torrentOwner:not(:has(abbr.timeago)):contains("Κυριακή") - # auto adjusted by site account profile - optional: true - remove: span - filters: - - name: replace - args: ["Uploaded ", ""] - - name: replace - args: [" by", ""] - - name: replace - args: ["Ανέβηκε ", ""] # Uploaded - - name: replace - args: [" από", ""] # by - - name: replace - args: [" τον/την", ""] # him/her - - name: replace - args: ["στις ", "at "] # at - - name: replace - args: ["σήμερα", "Today"] - - name: replace - args: ["εχθές", "Yesterday"] - - name: replace - args: ["Δευτέρα", "Monday"] - - name: replace - args: ["Τρίτη", "Tuesday"] - - name: replace - args: ["Τετάρτη", "Wednesday"] - - name: replace - args: ["Πέμπτη", "Thursday"] - - name: replace - args: ["Παρασκευή", "Friday"] - - name: replace - args: ["Σάββατο", "Saturday"] - - name: replace - args: ["Κυριακή", "Sunday"] - - name: fuzzytime - date_year: - # 23-10-2021 22:59 - selector: div.torrentOwner:not(:has(abbr.timeago)):contains("-") - # auto adjusted by site account profile - optional: true - remove: span - filters: - - name: replace - args: ["Uploaded ", ""] - - name: replace - args: [" by", ""] - - name: replace - args: ["at ", ""] - - name: replace - args: ["Ανέβηκε ", ""] # Uploaded - - name: replace - args: [" από", ""] # by - - name: replace - args: [" τον/την", ""] # him/her - - name: replace - args: ["στις ", ""] # at - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_other: - selector: td.torrent_name - # auto adjusted by site account profile - optional: true - remove: div, a, span - filters: - - name: replace - args: ["Uploaded ", ""] - - name: replace - args: [" by", ""] - - name: replace - args: ["at ", ""] - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - selector: div.torrentOwner > abbr.timeago - attribute: data-time - optional: true - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_other .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_other .Result.date_unix }}{{ else }}now{{ end }}" -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetcat.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetcat.yml deleted file mode 100644 index 19c2360a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetcat.yml +++ /dev/null @@ -1,119 +0,0 @@ ---- -id: magnetcat -name: Magnet Cat -description: "Magnet Cat is a Public Tracker for MOVIES / TV / GENERAL" -language: en-US -type: public -encoding: UTF-8 -# current domain finder https://魔法猫咪.lol/ or https://哆啦a猫.com/ -links: - - https://magnetcatcat.com/ - - https://clmclm.com/ - - https://www.8800548.xyz/ - - https://www.8800549.xyz/ - - https://www.8800550.xyz/ - - https://www.8800551.xyz/ -legacylinks: - - https://www.clm472.sbs/ - - https://www.8800535.xyz/ - - https://www.8800532.xyz/ - - https://www.8800533.xyz/ - - https://www.8800534.xyz/ - - https://www.8800536.xyz/ - - https://www.8800537.xyz/ - - https://www.8800538.xyz/ - - https://www.8800539.xyz/ - - https://www.8800540.xyz/ - - https://www.8800541.xyz/ - - https://www.8800542.xyz/ - - https://www.8800543.xyz/ - - https://www.8800544.xyz/ - - https://www.8800545.xyz/ - - https://www.8800546.xyz/ - - https://www.8800547.xyz/ - -caps: - categorymappings: - - {id: 影视, cat: TV, desc: 影视} - - {id: 影视, cat: Movies, desc: 影视} - - {id: 音乐, cat: Audio, desc: 音乐} - - {id: 图像, cat: Other, desc: 图像} - - {id: 文档书籍, cat: Books, desc: 文档书籍} - - {id: 压缩文件, cat: Other, desc: 压缩文件} - - {id: 安装包, cat: PC, desc: 安装包} - - {id: 其他, cat: Other, desc: 其他} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cat-id - type: select - label: Category - default: 0 - options: - 0: All categories - 1: Movies and TV shows - 2: Music - 3: Images - 4: Documents and books - 5: Compressed files - 6: Installer packages - 7: Other - - name: sort - type: select - label: Sort requested from site - default: 2 - options: - 2: created - 1: size - 0: relevance - -search: - paths: - - path: "search-{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}-{{ .Config.cat-id }}-{{ .Config.sort }}-1.html" - error: - - selector: :root:contains("Internal Server Error") - - rows: - selector: div.ssbox - - fields: - categorydesc: - selector: div.title > h3 > span - filters: - - name: re_replace - args: ["([\\[\\]]+)", ""] - title: - selector: a[href^="/hash/"] - details: - selector: a[href^="/hash/"] - attribute: href - infohash: - selector: a[href^="magnet:?xt="] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - date: - selector: div.sbar > span:contains("Date added:") > b, div.sbar > span:contains("添加时间:") > b - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd zzz" - size: - selector: div.sbar > span:contains("Size:") > b, div.sbar > span:contains("大小:") > b - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetdownload.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetdownload.yml deleted file mode 100644 index acc265f6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetdownload.yml +++ /dev/null @@ -1,96 +0,0 @@ ---- -id: magnetdownload -name: MagnetDownload -description: "MagnetDownload is a Public DHT Crawler" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.magnetdownload.com/ - -caps: - categorymappings: - - {id: Other, cat: Other, desc: Other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - -download: - before: - path: api/json_info - inputs: - # /info/14152933 - hashes: "{{ re_replace .DownloadUri.AbsolutePath \"/info/\" \"\" }}" - infohash: - usebeforeresponse: true - hash: - selector: :root - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: :root - filters: - - name: regexp - args: name\". \"(.+?)\" - - name: validfilename - -search: - paths: - # https://www.magnetdownload.com/search/2025/ - - path: "search/{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}/" - - rows: - selector: tr > td.x-item -# filters: -# - name: andmatch - - fields: - category: - text: Other - title: - selector: a - attribute: title - details: - selector: a - attribute: href - download: - selector: a - attribute: href - date: - # 2 days, 5 hours - selector: span.ctime - filters: - - name: timeago - files: - selector: div.tail - filters: - - name: regexp - args: Files. (\d+) - size: - selector: div.tail - filters: - - name: regexp - args: size. (.+?) T - grabs: - selector: div.tail - filters: - - name: regexp - args: requests. (\d+) - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetz.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetz.yml deleted file mode 100644 index aaa28055..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/magnetz.yml +++ /dev/null @@ -1,61 +0,0 @@ ---- -id: magnetz -name: Magnetz -description: "Magnetz is a Public torrent meta-search engine" -language: en-US -type: public -encoding: UTF-8 -links: - - https://magnetz.eu/ - -caps: - categorymappings: - - {id: Other, cat: Other, desc: Other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - paths: - - path: search - inputs: - query: "{{ if .Keywords }}{{ .Keywords }}{{ else }}*{{ end }}" - - rows: - selector: div.list-group-item - - fields: - category: - text: Other - title: - selector: a - details: - selector: a - attribute: href - download: - selector: a - attribute: href - date: - text: now - size: - selector: small - seeders: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/majomparade.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/majomparade.yml deleted file mode 100644 index a4b7fdc4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/majomparade.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: majomparade -name: Majomparádé -description: "Majomparádé (TurkDepo) is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://majomparade.eu/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 2, cat: Movies/3D, desc: "3D/Külföldi", default: true} - - {id: 1, cat: Movies/3D, desc: "3D/Magyar", default: true} - - {id: 3, cat: Movies/HD, desc: "Film/BR-BDRIP/Külföldi", default: true} - - {id: 4, cat: Movies/HD, desc: "Film/BR-BDRIP/Magyar", default: true} - - {id: 5, cat: Movies/SD, desc: "Film/Cam/Külföldi", default: true} - - {id: 6, cat: Movies/SD, desc: "Film/Cam/Magyar", default: true} - - {id: 7, cat: Movies/DVD, desc: "Film/DVD/Külföldi", default: true} - - {id: 8, cat: Movies/DVD, desc: "Film/DVD/Magyar", default: true} - - {id: 9, cat: Movies/DVD, desc: "Film/DVD9/Külföld", default: true} - - {id: 10, cat: Movies/DVD, desc: "Film/DVD9/Magyar", default: true} - - {id: 11, cat: Movies/HD, desc: "Film/HD/Külföldi", default: true} - - {id: 12, cat: Movies/HD, desc: "Film/HD/Magyar", default: true} - - {id: 13, cat: Movies/SD, desc: "Film/XviD/Külföldi", default: true} - - {id: 14, cat: Movies/SD, desc: "Film/XviD/Magyar", default: true} - - {id: 21, cat: Audio/Audiobook, desc: "Hangoskönyv", default: true} - - {id: 24, cat: PC/Games, desc: "Játék/ISO", default: true} - - {id: 25, cat: Console, desc: "Játék/Konzol", default: true} - - {id: 26, cat: PC/Games, desc: "Játék/RIP", default: true} - - {id: 22, cat: Books, desc: "Könyv/Külföldi", default: true} - - {id: 23, cat: Books, desc: "Könyv/Magyar", default: true} - - {id: 27, cat: Audio/Lossless, desc: "Lossless/Külföldi", default: true} - - {id: 28, cat: Audio/Lossless, desc: "Lossless/Magyar", default: true} - - {id: 31, cat: PC/Mobile-Other, desc: "Mobil", default: true} - - {id: 32, cat: PC/ISO, desc: "Programok/ISO", default: true} - - {id: 33, cat: PC/0day, desc: "Programok/RIP", default: true} - - {id: 20, cat: TV/HD, desc: "Sorozat/HD/Magyar", default: true} - - {id: 19, cat: TV/HD, desc: "Sorozat/HD/Külföldi", default: true} - - {id: 17, cat: TV/SD, desc: "Sorozat/Külföldi", default: true} - - {id: 18, cat: TV/SD, desc: "Sorozat/Magyar", default: true} - - {id: 15, cat: TV/Sport, desc: "Sport/Külföldi", default: true} - - {id: 16, cat: TV/Sport, desc: "Sport/Magyar", default: true} - - {id: 29, cat: Audio/Other, desc: "Zene/Külföldi", default: true} - - {id: 30, cat: Audio/Other, desc: "Zene/Magyar", default: true} - - {id: 34, cat: XXX/DVD, desc: "XXX/DVD", default: false} - - {id: 36, cat: XXX/x264, desc: "XXX/HD", default: false} - - {id: 35, cat: XXX/XviD, desc: "XXX/XviD", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 5: seeders - 3: size - 1: title - - name: type - type: select - label: Order requested from site - default: 0 - options: - 0: desc - 1: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek oldalanként: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Users who have been inactive for more than 25 weeks are automatically deleted." - -login: - # using cookie method because login page is using some some hidden security check - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index - selector: a[href^="/logout/"] - -search: - paths: - # https://majomparade.eu/torrents/?action=search&search_text=&incldead=0&categories[]=19&categories[]=18 - - path: torrents/ - categories: [2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 21, 24, 25, 26, 27, 28, 31, 32, 33, 20, 19, 17, 18, 15, 16, 29, 30] - # https://majomparade.net/torrents/turkalo/?action=search&search_text=&incldead=0&categories[]=19&categories[]=18 - - path: torrents/turkalo/ - categories: [2, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 21, 24, 25, 26, 27, 28, 31, 32, 33, 20, 19, 17, 18, 15, 16, 29, 30] - # https://majomparade.net/torrents/xxx/?action=search&search_text=&incldead=0&categories[]=34&categories[]=36&categories[]=35 - - path: torrents/xxx/ - categories: [34, 36, 35] - inputs: - action: search - search_text: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 all, 2 dead, 3 0.5x, 62 2x, 63 3x, 64 4x - incldead: 1 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - sort: "{{ .Config.sort }}" - order_by: "{{ .Config.type }}" - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - rows: - selector: article.torrent-card - - fields: - category: - selector: a[href*="&categories[]="] - attribute: href - filters: - - name: querystring - args: categories[] - title: - selector: a.torrent-title - attribute: data-name - details: - selector: a.torrent-title - attribute: href - download: - selector: a.torrent-title - attribute: data-dl - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: a.torrent-title - attribute: data-cover - size: - selector: div.torrent-card__meta-short span:nth-child(3) - grabs: - selector: a[href^="/torrent/snatches/"] - seeders: - selector: a[href^="/torrent/peers/"] - leechers: - selector: a[href$="leechers"] - date: - selector: div.torrent-card__meta-short span:nth-child(1) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - text: 1 - uploadvolumefactor: - case: - span.torrent-chip--boost:contains("0.5x"): 0.5 - span.torrent-chip--boost:contains("2x"): 2 - span.torrent-chip--boost:contains("3x"): 3 - span.torrent-chip--boost:contains("4x"): 4 - "*": 1 - _verification: - case: - span.torrent-chip--mod-ok: Verified - span.torrent-chip--mod-unknown: Unverified - span.torrent-chip--mod-bad: Blocked - genre: - selector: div.torrent-card__actions-row - remove: a, span.torrent-chip--boost, span.torrent-chip--mod-ok, span.torrent-chip--mod-unknown - description: - text: "{{ .Result._verification }}: {{ if .Result.genre }}{{ .Result.genre }}{{ else }}{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# Majomparádé v3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/makingoff.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/makingoff.yml deleted file mode 100644 index ae85c5fe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/makingoff.yml +++ /dev/null @@ -1,1077 +0,0 @@ ---- -id: makingoff -name: Making Off -description: "Making Off is a BRAZILIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pt-BR -type: private -encoding: iso-8859-1 -requestDelay: 5 -links: - - https://makingoff.org/ - -caps: - # commented out categories are discussion-only forums - categorymappings: - - {id: 23, cat: Movies, desc: "O Verdadeiro Cinema Está Aqui!"} - - {id: 461, cat: Movies, desc: " |-- África", default: true} - - {id: 26, cat: Movies, desc: " |-- América do Norte", default: true} - - {id: 24, cat: Movies, desc: " |-- Asiático", default: true} - - {id: 77, cat: Movies, desc: " |-- Curtas", default: true} - - {id: 28, cat: TV/Documentary, desc: " |-- Documentários", default: true} - - {id: 25, cat: Movies, desc: " |-- Europeu", default: true} - - {id: 29, cat: Movies, desc: " |-- Latino-Americano", default: true} - - {id: 27, cat: Movies, desc: " |-- Nacional", default: true} - - {id: 30, cat: Movies, desc: " |-- Oriente Médio", default: true} - - {id: 31, cat: Movies, desc: " |-- Oceania", default: true} - - {id: 15, cat: Audio, desc: " |-- Trilhas Sonoras", default: true} # mix of DDL and torrents, may cause errors - # - {id: 12, cat: Movies, desc: " |-- Portal"} - # - {id: 204, cat: Movies, desc: " |-- Dossiê Making Off"} - # - {id: 573, cat: Movies, desc: " |-- Festivais e Mostras"} - - {id: 173, cat: Movies, desc: " |-- Prata da Casa", default: true} - # - {id: 135, cat: Movies, desc: " |-- Diretores do Mês"} - # - {id: 190, cat: Movies, desc: " |-- Recomendações do Fórum"} - - {id: 36, cat: Movies, desc: "Diretores", default: true} - - {id: 460, cat: Movies, desc: " |-- Africanos"} - - {id: 632, cat: Movies, desc: " |---- Abdellatif Kechiche"} - - {id: 679, cat: Movies, desc: " |---- Abderrahmane Sissako"} - - {id: 483, cat: Movies, desc: " |---- Djibril Diop Mambéty"} - - {id: 884, cat: Movies, desc: " |---- Flora Gomes"} - - {id: 885, cat: Movies, desc: " |---- Gaston Kaboré"} - - {id: 745, cat: Movies, desc: " |---- Henry Barakat"} - - {id: 484, cat: Movies, desc: " |---- Idrissa Ouedraogo"} - - {id: 680, cat: Movies, desc: " |---- Mahamat-Saleh Haroun"} - - {id: 886, cat: Movies, desc: " |---- Merzak Allouache"} - - {id: 433, cat: Movies, desc: " |---- Ousmane Sembène"} - - {id: 574, cat: Movies, desc: " |---- Youssef Chahine"} - - {id: 75, cat: Movies, desc: " |-- Americanos e Canadenses"} - - {id: 199, cat: Movies, desc: " |---- Abel Ferrara"} - - {id: 141, cat: Movies, desc: " |---- Alan Parker"} - - {id: 682, cat: Movies, desc: " |---- Alex Gibney"} - - {id: 985, cat: Movies, desc: " |---- Alex Ross Perry"} - - {id: 818, cat: Movies, desc: " |---- Alexander Payne"} - - {id: 681, cat: Movies, desc: " |---- Allan Dwan"} - - {id: 437, cat: Movies, desc: " |---- Albert e David Maysles"} - - {id: 405, cat: Movies, desc: " |---- Anatole Litvak"} - - {id: 419, cat: Movies, desc: " |---- André de Toth"} - - {id: 335, cat: Movies, desc: " |---- Andrew V. McLaglen"} - - {id: 336, cat: Movies, desc: " |---- Andy Warhol"} - - {id: 234, cat: Movies, desc: " |---- Anthony Mann"} - - {id: 746, cat: Movies, desc: " |---- Arthur Hiller"} - - {id: 819, cat: Movies, desc: " |---- Arthur Lubin"} - - {id: 262, cat: Movies, desc: " |---- Arthur Penn"} - - {id: 304, cat: Movies, desc: " |---- Atom Egoyan"} - - {id: 883, cat: Movies, desc: " |---- Barbara Hammer"} - - {id: 438, cat: Movies, desc: " |---- Barry Levinson"} - - {id: 887, cat: Movies, desc: " |---- Benny & Josh Safdie"} - - {id: 820, cat: Movies, desc: " |---- Bernard Rose"} - - {id: 85, cat: Movies, desc: " |---- Billy Wilder"} - - {id: 125, cat: Movies, desc: " |---- Blake Edwards"} - - {id: 747, cat: Movies, desc: " |---- Bob Fosse"} - - {id: 515, cat: Movies, desc: " |---- Bob Rafelson"} - - {id: 142, cat: Movies, desc: " |---- Brian De Palma"} - - {id: 683, cat: Movies, desc: " |---- Budd Boetticher"} - - {id: 821, cat: Movies, desc: " |---- Busby Berkeley"} - - {id: 218, cat: Movies, desc: " |---- Buster Keaton"} - - {id: 888, cat: Movies, desc: " |---- Byron Haskin"} - - {id: 575, cat: Movies, desc: " |---- Cameron Crowe"} - - {id: 257, cat: Movies, desc: " |---- Cecil B. DeMille"} - - {id: 40, cat: Movies, desc: " |---- Charles Chaplin"} - - {id: 533, cat: Movies, desc: " |---- Charles Vidor"} - - {id: 684, cat: Movies, desc: " |---- Charles Walters"} - - {id: 889, cat: Movies, desc: " |---- Christopher Guest"} - - {id: 685, cat: Movies, desc: " |---- Clarence Brown"} - - {id: 88, cat: Movies, desc: " |---- Clint Eastwood"} - - {id: 748, cat: Movies, desc: " |---- Curtis Bernhardt"} - - {id: 822, cat: Movies, desc: " |---- Curtis Hanson"} - - {id: 890, cat: Movies, desc: " |---- Dan Curtis"} - - {id: 439, cat: Movies, desc: " |---- Daniel Mann"} - - {id: 485, cat: Movies, desc: " |---- Darren Aronofsky"} - - {id: 65, cat: Movies, desc: " |---- David Cronenberg"} - - {id: 440, cat: Movies, desc: " |---- David Fincher"} - - {id: 41, cat: Movies, desc: " |---- David Lynch"} - - {id: 441, cat: Movies, desc: " |---- David Mamet"} - - {id: 823, cat: Movies, desc: " |---- David Miller"} - - {id: 602, cat: Movies, desc: " |---- Delbert Mann"} - - {id: 421, cat: Movies, desc: " |---- Delmer Daves"} - - {id: 633, cat: Movies, desc: " |---- Denis Villeneuve"} - - {id: 462, cat: Movies, desc: " |---- Dennis Hopper"} - - {id: 182, cat: Movies, desc: " |---- Denys Arcand"} - - {id: 824, cat: Movies, desc: " |---- Don Hertzfeldt"} - - {id: 891, cat: Movies, desc: " |---- Don Medford"} - - {id: 249, cat: Movies, desc: " |---- Don Siegel"} - - {id: 263, cat: Movies, desc: " |---- Douglas Sirk"} - - {id: 208, cat: Movies, desc: " |---- D.W. Griffith"} - - {id: 406, cat: Movies, desc: " |---- Edmund Goulding"} - - {id: 253, cat: Movies, desc: " |---- Edward Dmytryk"} - - {id: 749, cat: Movies, desc: " |---- Edgar G. Ulmer "} - - {id: 750, cat: Movies, desc: " |---- Edwin L. Marin"} - - {id: 140, cat: Movies, desc: " |---- Elia Kazan"} - - {id: 576, cat: Movies, desc: " |---- Erich Von Stroheim"} - - {id: 231, cat: Movies, desc: " |---- Ernst Lubitsch"} - - {id: 368, cat: Movies, desc: " |---- Errol Morris"} - - {id: 825, cat: Movies, desc: " |---- Felix E. Feist"} - - {id: 91, cat: Movies, desc: " |---- Francis Ford Coppola"} - - {id: 389, cat: Movies, desc: " |---- Frank Borzage"} - - {id: 123, cat: Movies, desc: " |---- Frank Capra"} - - {id: 382, cat: Movies, desc: " |---- Franklin J. Schaffner"} - - {id: 751, cat: Movies, desc: " |---- Frank Tashlin"} - - {id: 369, cat: Movies, desc: " |---- Fred C. Newmeyer"} - - {id: 381, cat: Movies, desc: " |---- Fred Zinnemann"} - - {id: 892, cat: Movies, desc: " |---- Frederick Wiseman"} - - {id: 534, cat: Movies, desc: " |---- Gene Kelly"} - - {id: 383, cat: Movies, desc: " |---- George A. Romero"} - - {id: 250, cat: Movies, desc: " |---- George Cukor"} - - {id: 258, cat: Movies, desc: " |---- George Roy Hill"} - - {id: 407, cat: Movies, desc: " |---- George Seaton"} - - {id: 370, cat: Movies, desc: " |---- George Sidney"} - - {id: 143, cat: Movies, desc: " |---- George Stevens"} - - {id: 893, cat: Movies, desc: " |---- Gerd Oswald"} - - {id: 577, cat: Movies, desc: " |---- Godfrey Reggio"} - - {id: 536, cat: Movies, desc: " |---- Gordon Douglas"} - - {id: 442, cat: Movies, desc: " |---- Gregg Araki"} - - {id: 99, cat: Movies, desc: " |---- Gus Van Sant"} - - {id: 277, cat: Movies, desc: " |---- Guy Maddin"} - - {id: 752, cat: Movies, desc: " |---- H. C. Potter"} - - {id: 408, cat: Movies, desc: " |---- Hal Ashby"} - - {id: 371, cat: Movies, desc: " |---- Hal Hartley"} - - {id: 372, cat: Movies, desc: " |---- Hal Roach"} - - {id: 603, cat: Movies, desc: " |---- Harmony Korine"} - - {id: 373, cat: Movies, desc: " |---- Henry King"} - - {id: 443, cat: Movies, desc: " |---- Henry Koster"} - - {id: 251, cat: Movies, desc: " |---- Henry Hathaway"} - - {id: 826, cat: Movies, desc: " |---- Henry Levin"} - - {id: 486, cat: Movies, desc: " |---- Herbert Ross"} - - {id: 100, cat: Movies, desc: " |---- Howard Hawks"} - - {id: 367, cat: Movies, desc: " |---- Ida Lupino"} - - {id: 753, cat: Movies, desc: " |---- Irving Pichel"} - - {id: 374, cat: Movies, desc: " |---- J. Lee Thompson"} - - {id: 578, cat: Movies, desc: " |---- Jack Arnold"} - - {id: 604, cat: Movies, desc: " |---- Jack Conway"} - - {id: 259, cat: Movies, desc: " |---- Jacques Tourneur"} - - {id: 686, cat: Movies, desc: " |---- James Benning"} - - {id: 687, cat: Movies, desc: " |---- James Gray"} - - {id: 418, cat: Movies, desc: " |---- Jean Negulesco"} - - {id: 975, cat: Movies, desc: " |---- Jerry Lewis"} - - {id: 61, cat: Movies, desc: " |---- Jim Jarmusch"} - - {id: 688, cat: Movies, desc: " |---- Joe Dante"} - - {id: 754, cat: Movies, desc: " |---- Joe Swanberg"} - - {id: 90, cat: Movies, desc: " |---- Joel & Ethan Coen"} - - {id: 894, cat: Movies, desc: " |---- John Badham"} - - {id: 634, cat: Movies, desc: " |---- John Brahm"} - - {id: 375, cat: Movies, desc: " |---- John Carpenter"} - - {id: 189, cat: Movies, desc: " |---- John Cassavetes"} - - {id: 487, cat: Movies, desc: " |---- John Cromwell"} - - {id: 827, cat: Movies, desc: " |---- John Farrow"} - - {id: 101, cat: Movies, desc: " |---- John Ford"} - - {id: 260, cat: Movies, desc: " |---- John Frankenheimer"} - - {id: 895, cat: Movies, desc: " |---- John Hughes"} - - {id: 89, cat: Movies, desc: " |---- John Huston"} - - {id: 898, cat: Movies, desc: " |---- John Landis"} - - {id: 979, cat: Movies, desc: " |---- John Milius"} - - {id: 376, cat: Movies, desc: " |---- John Sayles"} - - {id: 365, cat: Movies, desc: " |---- John Schlesinger"} - - {id: 256, cat: Movies, desc: " |---- John Sturges"} - - {id: 254, cat: Movies, desc: " |---- John Waters"} - - {id: 605, cat: Movies, desc: " |---- Jonas Mekas"} - - {id: 689, cat: Movies, desc: " |---- Jonathan Demme"} - - {id: 378, cat: Movies, desc: " |---- Josef von Sternberg"} - - {id: 755, cat: Movies, desc: " |---- Joseph H. Lewis"} - - {id: 156, cat: Movies, desc: " |---- Joseph L. Mankiewicz"} - - {id: 690, cat: Movies, desc: " |---- Joseph Sargent"} - - {id: 206, cat: Movies, desc: " |---- Jules Dassin"} - - {id: 896, cat: Movies, desc: " |---- Kathryn Bigelow"} - - {id: 828, cat: Movies, desc: " |---- Kelly Reichardt"} - - {id: 463, cat: Movies, desc: " |---- Kenneth Anger"} - - {id: 897, cat: Movies, desc: " |---- Kevin Smith"} - - {id: 444, cat: Movies, desc: " |---- King Vidor"} - - {id: 606, cat: Movies, desc: " |---- Larry Clark "} - - {id: 978, cat: Movies, desc: " |---- Larry Cohen"} - - {id: 366, cat: Movies, desc: " |---- Lasse Hallström"} - - {id: 516, cat: Movies, desc: " |---- Lawrence Kasdan"} - - {id: 417, cat: Movies, desc: " |---- Leo McCarey"} - - {id: 517, cat: Movies, desc: " |---- Lewis Allen"} - - {id: 390, cat: Movies, desc: " |---- Lewis Milestone"} - - {id: 829, cat: Movies, desc: " |---- Lewis R. Foster"} - - {id: 830, cat: Movies, desc: " |---- M. Night Shyamalan"} - - {id: 464, cat: Movies, desc: " |---- Mark Robson"} - - {id: 635, cat: Movies, desc: " |---- Mark Rydell"} - - {id: 488, cat: Movies, desc: " |---- Mark Sandrich"} - - {id: 380, cat: Movies, desc: " |---- Martin Ritt"} - - {id: 45, cat: Movies, desc: " |---- Martin Scorsese"} - - {id: 899, cat: Movies, desc: " |---- Maya Deren"} - - {id: 363, cat: Movies, desc: " |---- Mel Brooks"} - - {id: 385, cat: Movies, desc: " |---- Mervyn LeRoy"} - - {id: 636, cat: Movies, desc: " |---- Michael Cimino"} - - {id: 255, cat: Movies, desc: " |---- Michael Curtiz"} - - {id: 489, cat: Movies, desc: " |---- Michael Mann"} - - {id: 154, cat: Movies, desc: " |---- Michael Moore"} - - {id: 102, cat: Movies, desc: " |---- Mike Nichols"} - - {id: 900, cat: Movies, desc: " |---- Mitchell Leisen"} - - {id: 537, cat: Movies, desc: " |---- Monte Hellman"} - - {id: 607, cat: Movies, desc: " |---- Neil LaBute"} - - {id: 221, cat: Movies, desc: " |---- Nicholas Ray"} - - {id: 637, cat: Movies, desc: " |---- Noah Baumbach"} - - {id: 157, cat: Movies, desc: " |---- Norman Jewison"} - - {id: 901, cat: Movies, desc: " |---- Norman Z. McLeod"} - - {id: 158, cat: Movies, desc: " |---- Oliver Stone"} - - {id: 97, cat: Movies, desc: " |---- Orson Welles"} - - {id: 159, cat: Movies, desc: " |---- Otto Preminger"} - - {id: 691, cat: Movies, desc: " |---- Paul Mazursky"} - - {id: 538, cat: Movies, desc: " |---- Paul Morrisey"} - - {id: 902, cat: Movies, desc: " |---- Paul Newman"} - - {id: 445, cat: Movies, desc: " |---- Paul Schrader"} - - {id: 160, cat: Movies, desc: " |---- Paul Thomas Anderson"} - - {id: 220, cat: Movies, desc: " |---- Peter Bogdanovich"} - - {id: 539, cat: Movies, desc: " |---- Phil Karlson"} - - {id: 638, cat: Movies, desc: " |---- Philip Kaufman"} - - {id: 410, cat: Movies, desc: " |---- Preston Sturges"} - - {id: 235, cat: Movies, desc: " |---- Quentin Tarantino"} - - {id: 756, cat: Movies, desc: " |---- Ralph Bakshi"} - - {id: 362, cat: Movies, desc: " |---- Ralph Nelson"} - - {id: 361, cat: Movies, desc: " |---- Raoul Walsh"} - - {id: 360, cat: Movies, desc: " |---- Richard Brooks"} - - {id: 903, cat: Movies, desc: " |---- Richard Donner"} - - {id: 359, cat: Movies, desc: " |---- Richard Fleischer"} - - {id: 118, cat: Movies, desc: " |---- Richard Linklater"} - - {id: 423, cat: Movies, desc: " |---- Richard Schickel"} - - {id: 757, cat: Movies, desc: " |---- Richard Thorpe"} - - {id: 280, cat: Movies, desc: " |---- Ridley Scott"} - - {id: 831, cat: Movies, desc: " |---- Rob Reiner"} - - {id: 161, cat: Movies, desc: " |---- Robert Aldrich"} - - {id: 184, cat: Movies, desc: " |---- Robert Altman"} - - {id: 832, cat: Movies, desc: " |---- Robert Benton"} - - {id: 904, cat: Movies, desc: " |---- Robert Florey"} - - {id: 409, cat: Movies, desc: " |---- Robert J. Flaherty"} - - {id: 358, cat: Movies, desc: " |---- Robert Mulligan"} - - {id: 692, cat: Movies, desc: " |---- Robert Rossen"} - - {id: 261, cat: Movies, desc: " |---- Robert Siodmak"} - - {id: 357, cat: Movies, desc: " |---- Robert Stevens"} - - {id: 168, cat: Movies, desc: " |---- Robert Wise"} - - {id: 758, cat: Movies, desc: " |---- Robert Z. Leonard"} - - {id: 905, cat: Movies, desc: " |---- Robert Zemeckis"} - - {id: 252, cat: Movies, desc: " |---- Roger Corman"} - - {id: 833, cat: Movies, desc: " |---- Ron Howard"} - - {id: 356, cat: Movies, desc: " |---- Rouben Mamoulian"} - - {id: 355, cat: Movies, desc: " |---- Rudolph Maté"} - - {id: 759, cat: Movies, desc: " |---- Russ Meyer"} - - {id: 834, cat: Movies, desc: " |---- Sam Mendes"} - - {id: 162, cat: Movies, desc: " |---- Sam Peckinpah"} - - {id: 906, cat: Movies, desc: " |---- Sam Raimi"} - - {id: 354, cat: Movies, desc: " |---- Sam Wood"} - - {id: 192, cat: Movies, desc: " |---- Samuel Fuller"} - - {id: 835, cat: Movies, desc: " |---- Sean Penn"} - - {id: 836, cat: Movies, desc: " |---- Sidney J. Furie"} - - {id: 119, cat: Movies, desc: " |---- Sidney Lumet"} - - {id: 608, cat: Movies, desc: " |---- Sofia Coppola"} - - {id: 490, cat: Movies, desc: " |---- Spike Jonze"} - - {id: 121, cat: Movies, desc: " |---- Spike Lee"} - - {id: 353, cat: Movies, desc: " |---- Stan Brakhage"} - - {id: 279, cat: Movies, desc: " |---- Stanley Donen"} - - {id: 333, cat: Movies, desc: " |---- Stanley Kramer"} - - {id: 48, cat: Movies, desc: " |---- Stanley Kubrick"} - - {id: 278, cat: Movies, desc: " |---- Steven Soderbergh"} - - {id: 124, cat: Movies, desc: " |---- Steven Spielberg"} - - {id: 332, cat: Movies, desc: " |---- Stuart Gordon"} - - {id: 907, cat: Movies, desc: " |---- Stuart Heisler"} - - {id: 693, cat: Movies, desc: " |---- Stuart Rosenberg"} - - {id: 163, cat: Movies, desc: " |---- Sydney Pollack"} - - {id: 837, cat: Movies, desc: " |---- Ted Tetzlaff"} - - {id: 422, cat: Movies, desc: " |---- Terrence Malick"} - - {id: 116, cat: Movies, desc: " |---- Terry Gilliam"} - - {id: 117, cat: Movies, desc: " |---- Tim Burton"} - - {id: 838, cat: Movies, desc: " |---- Tobe Hooper"} - - {id: 331, cat: Movies, desc: " |---- Tod Browning"} - - {id: 420, cat: Movies, desc: " |---- Todd Haynes"} - - {id: 110, cat: Movies, desc: " |---- Todd Solondz"} - - {id: 609, cat: Movies, desc: " |---- Tom DiCillo"} - - {id: 465, cat: Movies, desc: " |---- Victor Fleming"} - - {id: 201, cat: Movies, desc: " |---- Vincente Minnelli"} - - {id: 760, cat: Movies, desc: " |---- Vincent Sherman"} - - {id: 839, cat: Movies, desc: " |---- W. S. Van Dyke"} - - {id: 973, cat: Movies, desc: " |---- Wachowski Brothers"} - - {id: 631, cat: Movies, desc: " |---- Walter Hill "} - - {id: 639, cat: Movies, desc: " |---- Walter Lang"} - - {id: 579, cat: Movies, desc: " |---- Wayne Wang"} - - {id: 165, cat: Movies, desc: " |---- Wes Anderson"} - - {id: 761, cat: Movies, desc: " |---- Wes Craven"} - - {id: 762, cat: Movies, desc: " |---- Whit Stillman"} - - {id: 330, cat: Movies, desc: " |---- William A. Wellman"} - - {id: 491, cat: Movies, desc: " |---- William Castle"} - - {id: 329, cat: Movies, desc: " |---- William Dieterle"} - - {id: 328, cat: Movies, desc: " |---- William Friedkin"} - - {id: 166, cat: Movies, desc: " |---- William Wyler"} - - {id: 83, cat: Movies, desc: " |---- Woody Allen"} - - {id: 763, cat: Movies, desc: " |---- Xavier Dolan"} - - {id: 73, cat: Movies, desc: " |-- Asiáticos"} - - {id: 840, cat: Movies, desc: " |---- Akio Jissôji"} - - {id: 37, cat: Movies, desc: " |---- Akira Kurosawa"} - - {id: 283, cat: Movies, desc: " |---- Ang Lee"} - - {id: 580, cat: Movies, desc: " |---- Anh Hung Tran"} - - {id: 411, cat: Movies, desc: " |---- Apichatpong Weerasethakul"} - - {id: 492, cat: Movies, desc: " |---- Bong Joon-Ho"} - - {id: 518, cat: Movies, desc: " |---- Brillante Mendonza"} - - {id: 300, cat: Movies, desc: " |---- Chan-wook Park"} - - {id: 764, cat: Movies, desc: " |---- Chang Cheh"} - - {id: 841, cat: Movies, desc: " |---- Darezhan Omirbayev"} - - {id: 765, cat: Movies, desc: " |---- Edward Yang"} - - {id: 640, cat: Movies, desc: " |---- Gakuryu Ishii"} - - {id: 540, cat: Movies, desc: " |---- Hayao Miyazaki"} - - {id: 610, cat: Movies, desc: " |---- Hideo Gosha"} - - {id: 766, cat: Movies, desc: " |---- Hideo Nakata"} - - {id: 316, cat: Movies, desc: " |---- Hirokazu Koreeda"} - - {id: 541, cat: Movies, desc: " |---- Hiroshi Inagaki"} - - {id: 611, cat: Movies, desc: " |---- Hiroshi Shimizu"} - - {id: 211, cat: Movies, desc: " |---- Hiroshi Teshigahara"} - - {id: 320, cat: Movies, desc: " |---- Hsiao-hsien Hou"} - - {id: 612, cat: Movies, desc: " |---- Isao Takahata"} - - {id: 908, cat: Movies, desc: " |---- Isao Yukisada"} - - {id: 641, cat: Movies, desc: " |---- Ishirô Honda"} - - {id: 301, cat: Movies, desc: " |---- Ji-woon Kim"} - - {id: 694, cat: Movies, desc: " |---- John Woo"} - - {id: 695, cat: Movies, desc: " |---- Johnnie To"} - - {id: 767, cat: Movies, desc: " |---- Juzo Itami"} - - {id: 542, cat: Movies, desc: " |---- Kaneto Shindô"} - - {id: 909, cat: Movies, desc: " |---- Kazuyoshi Kumakiri"} - - {id: 696, cat: Movies, desc: " |---- Kei Kumai"} - - {id: 543, cat: Movies, desc: " |---- Keisuke Kinoshita"} - - {id: 613, cat: Movies, desc: " |---- Kenji Misumi "} - - {id: 614, cat: Movies, desc: " |---- Kihachi Okamoto"} - - {id: 70, cat: Movies, desc: " |---- Kim Ki-Duk"} - - {id: 412, cat: Movies, desc: " |---- Kiyoshi Kurosawa"} - - {id: 202, cat: Movies, desc: " |---- Kenji Mizoguchi"} - - {id: 697, cat: Movies, desc: " |---- Kinji Fukasaku"} - - {id: 476, cat: Movies, desc: " |---- Kôji Wakamatsu"} - - {id: 544, cat: Movies, desc: " |---- Kon Ichikawa"} - - {id: 581, cat: Movies, desc: " |---- Koreyoshi Kurahara"} - - {id: 698, cat: Movies, desc: " |---- Lav Diaz"} - - {id: 699, cat: Movies, desc: " |---- Lee Chang-dong"} - - {id: 910, cat: Movies, desc: " |---- Makoto Shinkai"} - - {id: 642, cat: Movies, desc: " |---- Mamoru Oshii"} - - {id: 911, cat: Movies, desc: " |---- Mani Kaul"} - - {id: 519, cat: Movies, desc: " |---- Masahiro Shinoda"} - - {id: 317, cat: Movies, desc: " |---- Masaki Kobayashi"} - - {id: 318, cat: Movies, desc: " |---- Mikio Naruse"} - - {id: 480, cat: Movies, desc: " |---- Mira Nair"} - - {id: 222, cat: Movies, desc: " |---- Nagisa Oshima"} - - {id: 912, cat: Movies, desc: " |---- Naoko Ogigami"} - - {id: 545, cat: Movies, desc: " |---- Naomi Kawase"} - - {id: 769, cat: Movies, desc: " |---- Noboru Tanaka"} - - {id: 768, cat: Movies, desc: " |---- Nobuhiko Obayashi"} - - {id: 546, cat: Movies, desc: " |---- Pen-Ek Ratanaruang"} - - {id: 700, cat: Movies, desc: " |---- Rithy Panh"} - - {id: 478, cat: Movies, desc: " |---- Sang-soo Hong"} - - {id: 479, cat: Movies, desc: " |---- Sanjay Leela Bhansali"} - - {id: 701, cat: Movies, desc: " |---- Satoshi Kon"} - - {id: 193, cat: Movies, desc: " |---- Satyajit Ray"} - - {id: 493, cat: Movies, desc: " |---- Seijun Suzuki"} - - {id: 770, cat: Movies, desc: " |---- Shinji Aoyama"} - - {id: 273, cat: Movies, desc: " |---- Shinya Tsukamoto"} - - {id: 547, cat: Movies, desc: " |---- Sion Sono"} - - {id: 319, cat: Movies, desc: " |---- Shohei Imamura"} - - {id: 477, cat: Movies, desc: " |---- Shuji Terayama"} - - {id: 615, cat: Movies, desc: " |---- Shunji Iwai"} - - {id: 771, cat: Movies, desc: " |---- Tadashi Imai"} - - {id: 94, cat: Movies, desc: " |---- Takashi Miike"} - - {id: 66, cat: Movies, desc: " |---- Takeshi Kitano"} - - {id: 772, cat: Movies, desc: " |---- Tetsuya Nakashima"} - - {id: 773, cat: Movies, desc: " |---- Tomu Uchida"} - - {id: 105, cat: Movies, desc: " |---- Tsai Ming-liang"} - - {id: 842, cat: Movies, desc: " |---- Tsui Hark"} - - {id: 974, cat: Movies, desc: " |---- Wang Bing"} - - {id: 167, cat: Movies, desc: " |---- Wong Kar-wai"} - - {id: 549, cat: Movies, desc: " |---- Yasuzô Masumura"} - - {id: 548, cat: Movies, desc: " |---- Yoshishige Yoshida"} - - {id: 107, cat: Movies, desc: " |---- Yasujiro Ozu"} - - {id: 120, cat: Movies, desc: " |---- Yimou Zhang"} - - {id: 446, cat: Movies, desc: " |---- Yôji Yamada"} - - {id: 774, cat: Movies, desc: " |---- Yôjirô Takita"} - - {id: 702, cat: Movies, desc: " |---- Yoshimitsu Morita"} - - {id: 520, cat: Movies, desc: " |---- Zhang Ke Jia"} - - {id: 74, cat: Movies, desc: " |-- Brasileiros"} - - {id: 915, cat: Movies, desc: " |---- Adirley Queirós"} - - {id: 981, cat: Movies, desc: " |---- Agnaldo Siri Azevedo"} - - {id: 643, cat: Movies, desc: " |---- Alfredo Sternheim"} - - {id: 703, cat: Movies, desc: " |---- Alberto Pieralise"} - - {id: 704, cat: Movies, desc: " |---- Alberto Salvá"} - - {id: 843, cat: Movies, desc: " |---- Aloysio Raulino"} - - {id: 916, cat: Movies, desc: " |---- Aly Muritiba"} - - {id: 649, cat: Movies, desc: " |---- Amácio Mazzaropi"} - - {id: 241, cat: Movies, desc: " |---- Ana Carolina"} - - {id: 494, cat: Movies, desc: " |---- André Klotzel"} - - {id: 616, cat: Movies, desc: " |---- André Luís de Oliveira"} - - {id: 917, cat: Movies, desc: " |---- André Novais Oliveira"} - - {id: 617, cat: Movies, desc: " |---- Andrea Tonacci"} - - {id: 582, cat: Movies, desc: " |---- Andrucha Waddington"} - - {id: 844, cat: Movies, desc: " |---- Anna Muylaert"} - - {id: 242, cat: Movies, desc: " |---- Anselmo Duarte"} - - {id: 705, cat: Movies, desc: " |---- Antônio Calmon"} - - {id: 706, cat: Movies, desc: " |---- Antonio Carlos Fontoura"} - - {id: 136, cat: Movies, desc: " |---- Arnaldo Jabor"} - - {id: 707, cat: Movies, desc: " |---- Aurélio Teixeira"} - - {id: 918, cat: Movies, desc: " |---- Bárbara Wagner & Benjamin de Burca"} - - {id: 243, cat: Movies, desc: " |---- Beto Brant"} - - {id: 618, cat: Movies, desc: " |---- Braz Chediak"} - - {id: 244, cat: Movies, desc: " |---- Bruno Barreto"} - - {id: 845, cat: Movies, desc: " |---- Bruno Safadi"} - - {id: 619, cat: Movies, desc: " |---- Cao Guimarães"} - - {id: 644, cat: Movies, desc: " |---- Carlos Alberto Prates Correa"} - - {id: 620, cat: Movies, desc: " |---- Carlos Coimbra"} - - {id: 238, cat: Movies, desc: " |---- Carlos Diegues"} - - {id: 550, cat: Movies, desc: " |---- Carlos Gerbase"} - - {id: 551, cat: Movies, desc: " |---- Carlos Hugo Christensen"} - - {id: 435, cat: Movies, desc: " |---- Carlos Manga"} - - {id: 846, cat: Movies, desc: " |---- Carlos Nader"} - - {id: 225, cat: Movies, desc: " |---- Carlos Reichenbach"} - - {id: 847, cat: Movies, desc: " |---- Cláudio Assis"} - - {id: 708, cat: Movies, desc: " |---- Cláudio Cunha"} - - {id: 919, cat: Movies, desc: " |---- Cristiano Burlan"} - - {id: 775, cat: Movies, desc: " |---- Daniel Filho"} - - {id: 621, cat: Movies, desc: " |---- David Neves"} - - {id: 387, cat: Movies, desc: " |---- Domingos de Oliveira"} - - {id: 81, cat: Movies, desc: " |---- Eduardo Coutinho"} - - {id: 622, cat: Movies, desc: " |---- Eduardo Escorel"} - - {id: 848, cat: Movies, desc: " |---- Eliane Caffé"} - - {id: 709, cat: Movies, desc: " |---- Elyseu Visconti"} - - {id: 849, cat: Movies, desc: " |---- Eryk Rocha"} - - {id: 710, cat: Movies, desc: " |---- Eurípides Ramos"} - - {id: 711, cat: Movies, desc: " |---- Fabio Barreto"} - - {id: 712, cat: Movies, desc: " |---- Fauzi Mansur"} - - {id: 713, cat: Movies, desc: " |---- Fernando de Barros"} - - {id: 552, cat: Movies, desc: " |---- Fernando Meirelles"} - - {id: 714, cat: Movies, desc: " |---- Flávio Tambellini"} - - {id: 920, cat: Movies, desc: " |---- Francisco Cavalcanti"} - - {id: 413, cat: Movies, desc: " |---- Francisco Ramalho Jr."} - - {id: 715, cat: Movies, desc: " |---- Gabriel Mascaro"} - - {id: 776, cat: Movies, desc: " |---- Geraldo Sarno"} - - {id: 977, cat: Movies, desc: " |---- Geraldo Vietri"} - - {id: 921, cat: Movies, desc: " |---- Gerson Tavares"} - - {id: 62, cat: Movies, desc: " |---- Glauber Rocha"} - - {id: 623, cat: Movies, desc: " |---- Guilherme de Almeida Prado"} - - {id: 228, cat: Movies, desc: " |---- Hector Babenco"} - - {id: 925, cat: Movies, desc: " |---- Heitor Dhalia"} - - {id: 922, cat: Movies, desc: " |---- Helena Ignez"} - - {id: 647, cat: Movies, desc: " |---- Helvécio Ratton"} - - {id: 583, cat: Movies, desc: " |---- Hugo Carvana"} - - {id: 215, cat: Movies, desc: " |---- Humberto Mauro"} - - {id: 553, cat: Movies, desc: " |---- Ivan Cardoso"} - - {id: 645, cat: Movies, desc: " |---- J. B. Tanko"} - - {id: 191, cat: Movies, desc: " |---- Jairo Ferreira"} - - {id: 646, cat: Movies, desc: " |---- Jean Garret"} - - {id: 850, cat: Movies, desc: " |---- Jece Valadão"} - - {id: 584, cat: Movies, desc: " |---- João Batista Andrade"} - - {id: 923, cat: Movies, desc: " |---- João Moreira Salles"} - - {id: 203, cat: Movies, desc: " |---- Joaquim Pedro De Andrade"} - - {id: 976, cat: Movies, desc: " |---- Jom Tob Azulay"} - - {id: 851, cat: Movies, desc: " |---- Jorge Durán"} - - {id: 155, cat: Movies, desc: " |---- Jorge Furtado"} - - {id: 777, cat: Movies, desc: " |---- José Carlos Burle"} - - {id: 852, cat: Movies, desc: " |---- José Eduardo Belmonte"} - - {id: 436, cat: Movies, desc: " |---- José Joffily"} - - {id: 924, cat: Movies, desc: " |---- José Miziara"} - - {id: 82, cat: Movies, desc: " |---- José Mojica Marins"} - - {id: 853, cat: Movies, desc: " |---- José Padilha"} - - {id: 854, cat: Movies, desc: " |---- Juliana Rojas"} - - {id: 152, cat: Movies, desc: " |---- Júlio Bressane"} - - {id: 554, cat: Movies, desc: " |---- Karim Aïnouz"} - - {id: 555, cat: Movies, desc: " |---- Kleber Mendonça Filho"} - - {id: 239, cat: Movies, desc: " |---- Leon Hirszman"} - - {id: 855, cat: Movies, desc: " |---- Lírio Ferreira"} - - {id: 245, cat: Movies, desc: " |---- Lucia Murat"} - - {id: 466, cat: Movies, desc: " |---- Luis Sergio Person"} - - {id: 585, cat: Movies, desc: " |---- Luiz Carlos Lacerda"} - - {id: 778, cat: Movies, desc: " |---- Luiz Rosemberg Filho "} - - {id: 779, cat: Movies, desc: " |---- Marcelo Galvão"} - - {id: 716, cat: Movies, desc: " |---- Marcelo Gomes"} - - {id: 624, cat: Movies, desc: " |---- Marcelo Masagão"} - - {id: 717, cat: Movies, desc: " |---- Marcelo Pedroso"} - - {id: 718, cat: Movies, desc: " |---- Marco Altberg "} - - {id: 856, cat: Movies, desc: " |---- Marcos Farias"} - - {id: 926, cat: Movies, desc: " |---- Maria Augusta Ramos"} - - {id: 648, cat: Movies, desc: " |---- Maurice Capovilla"} - - {id: 719, cat: Movies, desc: " |---- Miguel Borges"} - - {id: 556, cat: Movies, desc: " |---- Miguel Faria Jr."} - - {id: 248, cat: Movies, desc: " |---- Murilo Salles"} - - {id: 122, cat: Movies, desc: " |---- Nelson Pereira dos Santos"} - - {id: 720, cat: Movies, desc: " |---- Neville de Almeida"} - - {id: 650, cat: Movies, desc: " |---- Ody Fraga"} - - {id: 927, cat: Movies, desc: " |---- Olney São Paulo"} - - {id: 721, cat: Movies, desc: " |---- Osvaldo de Oliveira"} - - {id: 722, cat: Movies, desc: " |---- Oswaldo Caldeira"} - - {id: 379, cat: Movies, desc: " |---- Ozualdo Candeias"} - - {id: 986, cat: Movies, desc: " |---- Paula Gaitán"} - - {id: 495, cat: Movies, desc: " |---- Paulo Cesar Saraceni"} - - {id: 586, cat: Movies, desc: " |---- Paulo Thiago"} - - {id: 928, cat: Movies, desc: " |---- Pedro Carlos Rovai"} - - {id: 625, cat: Movies, desc: " |---- Petrus Cariry"} - - {id: 723, cat: Movies, desc: " |---- Reginaldo Faria"} - - {id: 557, cat: Movies, desc: " |---- Renato Tapajós"} - - {id: 983, cat: Movies, desc: " |---- Ricardo Alves Jr."} - - {id: 496, cat: Movies, desc: " |---- Roberto Farias"} - - {id: 467, cat: Movies, desc: " |---- Roberto Pires"} - - {id: 626, cat: Movies, desc: " |---- Roberto Santos"} - - {id: 106, cat: Movies, desc: " |---- Rogério Sganzerla"} - - {id: 587, cat: Movies, desc: " |---- Rosemberg Cariry"} - - {id: 108, cat: Movies, desc: " |---- Ruy Guerra"} - - {id: 929, cat: Movies, desc: " |---- Sandra Kogut"} - - {id: 588, cat: Movies, desc: " |---- Sandra Werneck"} - - {id: 384, cat: Movies, desc: " |---- Sérgio Bianchi"} - - {id: 236, cat: Movies, desc: " |---- Sérgio Rezende"} - - {id: 780, cat: Movies, desc: " |---- Sérgio Ricardo"} - - {id: 240, cat: Movies, desc: " |---- Silvio Tendler"} - - {id: 468, cat: Movies, desc: " |---- Sylvio Back"} - - {id: 857, cat: Movies, desc: " |---- Tata Amaral"} - - {id: 724, cat: Movies, desc: " |---- Tom Payne"} - - {id: 497, cat: Movies, desc: " |---- Toni Venturi"} - - {id: 246, cat: Movies, desc: " |---- Ugo Giorgetti "} - - {id: 781, cat: Movies, desc: " |---- Victor di Mello"} - - {id: 558, cat: Movies, desc: " |---- Victor Lima"} - - {id: 247, cat: Movies, desc: " |---- Vladimir Carvalho"} - - {id: 782, cat: Movies, desc: " |---- Walter Carvalho"} - - {id: 164, cat: Movies, desc: " |---- Walter Hugo Khouri"} - - {id: 237, cat: Movies, desc: " |---- Walter Lima Jr."} - - {id: 84, cat: Movies, desc: " |---- Walter Salles"} - - {id: 627, cat: Movies, desc: " |---- Watson Macedo"} - - {id: 982, cat: Movies, desc: " |---- Wolney Oliveira"} - - {id: 628, cat: Movies, desc: " |---- Xavier de Oliveira"} - - {id: 589, cat: Movies, desc: " |---- Zelito Viana"} - - {id: 187, cat: Movies, desc: " |-- Britânicos"} - - {id: 783, cat: Movies, desc: " |---- Alan Clarke"} - - {id: 391, cat: Movies, desc: " |---- Alberto Cavalcanti"} - - {id: 474, cat: Movies, desc: " |---- Alexander Mackendrick"} - - {id: 39, cat: Movies, desc: " |---- Alfred Hitchcock"} - - {id: 559, cat: Movies, desc: " |---- Anthony Asquith"} - - {id: 560, cat: Movies, desc: " |---- Anthony Minghella"} - - {id: 651, cat: Movies, desc: " |---- Bryan Forbes"} - - {id: 268, cat: Movies, desc: " |---- Carol Reed"} - - {id: 284, cat: Movies, desc: " |---- Danny Boyle"} - - {id: 148, cat: Movies, desc: " |---- David Lean"} - - {id: 858, cat: Movies, desc: " |---- David Mackenzie"} - - {id: 285, cat: Movies, desc: " |---- Derek Jarman"} - - {id: 930, cat: Movies, desc: " |---- Freddie Francis"} - - {id: 725, cat: Movies, desc: " |---- Jack Clayton"} - - {id: 286, cat: Movies, desc: " |---- James Ivory"} - - {id: 784, cat: Movies, desc: " |---- Jim Sheridan"} - - {id: 276, cat: Movies, desc: " |---- John Boorman "} - - {id: 931, cat: Movies, desc: " |---- John Guillermin"} - - {id: 219, cat: Movies, desc: " |---- Joseph Losey"} - - {id: 498, cat: Movies, desc: " |---- Karel Reisz"} - - {id: 113, cat: Movies, desc: " |---- Ken Loach"} - - {id: 282, cat: Movies, desc: " |---- Ken Russell"} - - {id: 207, cat: Movies, desc: " |---- Kenneth Branagh"} - - {id: 859, cat: Movies, desc: " |---- Kevin Macdonald"} - - {id: 414, cat: Movies, desc: " |---- Lewis Gilbert"} - - {id: 499, cat: Movies, desc: " |---- Lindsay Anderson"} - - {id: 500, cat: Movies, desc: " |---- Lynne Ramsay"} - - {id: 475, cat: Movies, desc: " |---- Michael Anderson"} - - {id: 287, cat: Movies, desc: " |---- Michael Powell"} - - {id: 958, cat: Movies, desc: " |---- Michael Radford"} - - {id: 150, cat: Movies, desc: " |---- Michael Winterbottom"} - - {id: 652, cat: Movies, desc: " |---- Mike Figgis"} - - {id: 291, cat: Movies, desc: " |---- Mike Leigh"} - - {id: 404, cat: Movies, desc: " |---- Nicolas Roeg"} - - {id: 416, cat: Movies, desc: " |---- Norman McLaren"} - - {id: 653, cat: Movies, desc: " |---- Peter Brook"} - - {id: 96, cat: Movies, desc: " |---- Peter Greenaway"} - - {id: 364, cat: Movies, desc: " |---- Peter Watkins"} - - {id: 288, cat: Movies, desc: " |---- Peter Yates"} - - {id: 281, cat: Movies, desc: " |---- Richard Attenborough"} - - {id: 289, cat: Movies, desc: " |---- Richard Lester"} - - {id: 932, cat: Movies, desc: " |---- Robert Hamer"} - - {id: 726, cat: Movies, desc: " |---- Robert Stevenson"} - - {id: 377, cat: Movies, desc: " |---- Ronald Neame"} - - {id: 727, cat: Movies, desc: " |---- Roy Ward Baker"} - - {id: 130, cat: Movies, desc: " |---- Stephen Frears"} - - {id: 785, cat: Movies, desc: " |---- Terence Davies"} - - {id: 415, cat: Movies, desc: " |---- Terence Fisher"} - - {id: 290, cat: Movies, desc: " |---- Tony Richardson"} - - {id: 72, cat: Movies, desc: " |-- Europeus (outros)"} - - {id: 307, cat: Movies, desc: " |---- Agnieszka Holland"} - - {id: 38, cat: Movies, desc: " |---- Aki Kaurismäki"} - - {id: 501, cat: Movies, desc: " |---- Alain Tanner"} - - {id: 424, cat: Movies, desc: " |---- Aleksandr Dovzhenko"} - - {id: 469, cat: Movies, desc: " |---- Aleksandr Petrov"} - - {id: 171, cat: Movies, desc: " |---- Aleksandr Sokurov"} - - {id: 786, cat: Movies, desc: " |---- Aleksei German"} - - {id: 400, cat: Movies, desc: " |---- Aleksey Balabanov"} - - {id: 521, cat: Movies, desc: " |---- Alex van Warmerdam"} - - {id: 274, cat: Movies, desc: " |---- Alexander Kluge"} - - {id: 860, cat: Movies, desc: " |---- André Delvaux"} - - {id: 453, cat: Movies, desc: " |---- Andrei Konchalovsky"} - - {id: 67, cat: Movies, desc: " |---- Andrei Tarkovsky"} - - {id: 933, cat: Movies, desc: " |---- Andrey Zvyagintsev"} - - {id: 104, cat: Movies, desc: " |---- Andrzej Wajda"} - - {id: 275, cat: Movies, desc: " |---- Andrzej Zulawski"} - - {id: 934, cat: Movies, desc: " |---- Angela Schanelec"} - - {id: 935, cat: Movies, desc: " |---- Arne Sucksdorff"} - - {id: 293, cat: Movies, desc: " |---- Artavazd Peleshian"} - - {id: 728, cat: Movies, desc: " |---- Baltasar Kormákur"} - - {id: 223, cat: Movies, desc: " |---- Béla Tarr"} - - {id: 936, cat: Movies, desc: " |---- Bert Haanstra"} - - {id: 454, cat: Movies, desc: " |---- Bille August"} - - {id: 729, cat: Movies, desc: " |---- Boris Barnet"} - - {id: 170, cat: Movies, desc: " |---- Carl Theodor Dreyer"} - - {id: 401, cat: Movies, desc: " |---- Chantal Akerman"} - - {id: 561, cat: Movies, desc: " |---- Christian Petzold"} - - {id: 590, cat: Movies, desc: " |---- Christoffer Boe"} - - {id: 937, cat: Movies, desc: " |---- Christoph Hochhäusler"} - - {id: 861, cat: Movies, desc: " |---- Corneliu Porumboiu"} - - {id: 137, cat: Movies, desc: " |---- Costa-Gavras"} - - {id: 862, cat: Movies, desc: " |---- Cristi Puiu"} - - {id: 562, cat: Movies, desc: " |---- Cristian Mungiu"} - - {id: 787, cat: Movies, desc: " |---- Dagur Kári"} - - {id: 654, cat: Movies, desc: " |---- Danis Tanovic"} - - {id: 591, cat: Movies, desc: " |---- Dorota Kedzierzawska"} - - {id: 426, cat: Movies, desc: " |---- Dusan Makavejev"} - - {id: 402, cat: Movies, desc: " |---- Dziga Vertov"} - - {id: 788, cat: Movies, desc: " |---- Elem Klimov"} - - {id: 59, cat: Movies, desc: " |---- Emir Kusturica"} - - {id: 789, cat: Movies, desc: " |---- Evald Schorm"} - - {id: 149, cat: Movies, desc: " |---- Fatih Akin"} - - {id: 655, cat: Movies, desc: " |---- Frantisek Vlácil"} - - {id: 502, cat: Movies, desc: " |---- Fridrik Thor Fridriksson"} - - {id: 169, cat: Movies, desc: " |---- Friedrich Wilhelm Murnau"} - - {id: 95, cat: Movies, desc: " |---- Fritz Lang"} - - {id: 297, cat: Movies, desc: " |---- Georg Wilhelm Pabst"} - - {id: 592, cat: Movies, desc: " |---- Georgi Daneliya"} - - {id: 938, cat: Movies, desc: " |---- Goran Paskaljevic"} - - {id: 522, cat: Movies, desc: " |---- Grigori Kozintsev"} - - {id: 205, cat: Movies, desc: " |---- Harun Farocki"} - - {id: 298, cat: Movies, desc: " |---- Helmut Käutner"} - - {id: 730, cat: Movies, desc: " |---- Henri Storck"} - - {id: 42, cat: Movies, desc: " |---- Ingmar Bergman"} - - {id: 312, cat: Movies, desc: " |---- István Szabó"} - - {id: 523, cat: Movies, desc: " |---- Jan Hrebejk"} - - {id: 790, cat: Movies, desc: " |---- Jan Nemec"} - - {id: 731, cat: Movies, desc: " |---- Jan Troell"} - - {id: 132, cat: Movies, desc: " |---- Jan Svankmajer"} - - {id: 791, cat: Movies, desc: " |---- Jaromil Jires"} - - {id: 209, cat: Movies, desc: " |---- Jean-Pierre & Luc Dardenne"} - - {id: 863, cat: Movies, desc: " |---- Jerzy Kawalerowicz"} - - {id: 386, cat: Movies, desc: " |---- Jerzy Skolimowski"} - - {id: 503, cat: Movies, desc: " |---- Jirí Menzel"} - - {id: 864, cat: Movies, desc: " |---- Joachim Lafosse"} - - {id: 563, cat: Movies, desc: " |---- Johan van der Keuken"} - - {id: 939, cat: Movies, desc: " |---- John Carney"} - - {id: 792, cat: Movies, desc: " |---- Joris Ivens"} - - {id: 504, cat: Movies, desc: " |---- Jos Stelling"} - - {id: 793, cat: Movies, desc: " |---- Juraj Herz "} - - {id: 656, cat: Movies, desc: " |---- Juraj Jakubisko"} - - {id: 657, cat: Movies, desc: " |---- Karel Kachyna"} - - {id: 658, cat: Movies, desc: " |---- Karel Zeman"} - - {id: 940, cat: Movies, desc: " |---- Kira Muratova"} - - {id: 732, cat: Movies, desc: " |---- Konstantin Lopushanskiy"} - - {id: 941, cat: Movies, desc: " |---- Kornél Mundruczó"} - - {id: 69, cat: Movies, desc: " |---- Krzysztof Kieslowski"} - - {id: 470, cat: Movies, desc: " |---- Krzysztof Zanussi"} - - {id: 63, cat: Movies, desc: " |---- Lars Von Trier"} - - {id: 659, cat: Movies, desc: " |---- Lech Majewski"} - - {id: 403, cat: Movies, desc: " |---- Leni Riefenstahl"} - - {id: 505, cat: Movies, desc: " |---- Lev Kuleshov"} - - {id: 660, cat: Movies, desc: " |---- Lucas Belvaux"} - - {id: 733, cat: Movies, desc: " |---- Lucian Pintilie"} - - {id: 309, cat: Movies, desc: " |---- Lukas Moodysson"} - - {id: 524, cat: Movies, desc: " |---- Margarethe von Trotta"} - - {id: 734, cat: Movies, desc: " |---- Martin Sulík"} - - {id: 60, cat: Movies, desc: " |---- Michael Haneke"} - - {id: 865, cat: Movies, desc: " |---- Mika Kaurismäki"} - - {id: 272, cat: Movies, desc: " |---- Miklós Jancsó"} - - {id: 506, cat: Movies, desc: " |---- Mikhail Kalatozov"} - - {id: 942, cat: Movies, desc: " |---- Mikhail Romm"} - - {id: 46, cat: Movies, desc: " |---- Milos Forman"} - - {id: 866, cat: Movies, desc: " |---- Nanouk Leopold"} - - {id: 131, cat: Movies, desc: " |---- Neil Jordan"} - - {id: 507, cat: Movies, desc: " |---- Nicolas Winding Refn"} - - {id: 311, cat: Movies, desc: " |---- Nikita Mikhalkov"} - - {id: 564, cat: Movies, desc: " |---- Ole Christian Madsen"} - - {id: 294, cat: Movies, desc: " |---- Otar Iosseliani"} - - {id: 295, cat: Movies, desc: " |---- Paul Verhoeven"} - - {id: 794, cat: Movies, desc: " |---- Pavel Lungin"} - - {id: 943, cat: Movies, desc: " |---- Piotr Szulkin"} - - {id: 867, cat: Movies, desc: " |---- Radu Jude"} - - {id: 47, cat: Movies, desc: " |---- Rainer Werner Fassbinder"} - - {id: 944, cat: Movies, desc: " |---- Rein Raamat"} - - {id: 661, cat: Movies, desc: " |---- Roy Andersson"} - - {id: 795, cat: Movies, desc: " |---- Rúnar Rúnarsson"} - - {id: 735, cat: Movies, desc: " |---- Sergei Loznitsa"} - - {id: 133, cat: Movies, desc: " |---- Sergei M. Eisenstein"} - - {id: 196, cat: Movies, desc: " |---- Sergei Paradjanov"} - - {id: 796, cat: Movies, desc: " |---- Sergiu Nicolaescu"} - - {id: 508, cat: Movies, desc: " |---- Sharunas Bartas"} - - {id: 736, cat: Movies, desc: " |---- Stefan Uher"} - - {id: 455, cat: Movies, desc: " |---- Susanne Bier"} - - {id: 945, cat: Movies, desc: " |---- Takis Kanellopoulos"} - - {id: 868, cat: Movies, desc: " |---- Tengiz Abuladze"} - - {id: 176, cat: Movies, desc: " |---- Theo Angelopoulos"} - - {id: 946, cat: Movies, desc: " |---- Thomas Arslan"} - - {id: 471, cat: Movies, desc: " |---- Thomas Vinterberg"} - - {id: 299, cat: Movies, desc: " |---- Tom Tykwer"} - - {id: 662, cat: Movies, desc: " |---- Ulrich Seidl"} - - {id: 229, cat: Movies, desc: " |---- Vera Chytilová"} - - {id: 310, cat: Movies, desc: " |---- Victor Sjöström"} - - {id: 456, cat: Movies, desc: " |---- Volker Schlöndorff"} - - {id: 869, cat: Movies, desc: " |---- Vsevolod Pudovkin"} - - {id: 663, cat: Movies, desc: " |---- Walerian Borowczyk"} - - {id: 49, cat: Movies, desc: " |---- Werner Herzog"} - - {id: 472, cat: Movies, desc: " |---- Werner Schroeter"} - - {id: 111, cat: Movies, desc: " |---- Wim Wenders"} - - {id: 947, cat: Movies, desc: " |---- Witold Leszczynski"} - - {id: 797, cat: Movies, desc: " |---- Wladyslaw Starewicz "} - - {id: 473, cat: Movies, desc: " |---- Yevgeni Bauer"} - - {id: 948, cat: Movies, desc: " |---- Yorgos Lanthimos"} - - {id: 313, cat: Movies, desc: " |---- Zoltán Fábri"} - - {id: 138, cat: Movies, desc: " |-- Franceses"} - - {id: 565, cat: Movies, desc: " |---- Abel Gance"} - - {id: 200, cat: Movies, desc: " |---- Agnes Varda"} - - {id: 509, cat: Movies, desc: " |---- Alain Corneau"} - - {id: 870, cat: Movies, desc: " |---- Alain Guiraudie "} - - {id: 78, cat: Movies, desc: " |---- Alain Resnais"} - - {id: 430, cat: Movies, desc: " |---- Alain Robbe-Grillet"} - - {id: 798, cat: Movies, desc: " |---- Alexandre Astruc"} - - {id: 871, cat: Movies, desc: " |---- Alice Guy"} - - {id: 338, cat: Movies, desc: " |---- André Téchiné"} - - {id: 799, cat: Movies, desc: " |---- Anne Fontaine"} - - {id: 872, cat: Movies, desc: " |---- Arnaud Desplechin"} - - {id: 339, cat: Movies, desc: " |---- Barbet Schroeder"} - - {id: 593, cat: Movies, desc: " |---- Benoît Jacquot"} - - {id: 395, cat: Movies, desc: " |---- Bertrand Blier"} - - {id: 510, cat: Movies, desc: " |---- Bertrand Bonello"} - - {id: 396, cat: Movies, desc: " |---- Bertrand Tavernier"} - - {id: 427, cat: Movies, desc: " |---- Bruno Dumont"} - - {id: 266, cat: Movies, desc: " |---- Catherine Breillat"} - - {id: 980, cat: Movies, desc: " |---- Cédric Kahn"} - - {id: 447, cat: Movies, desc: " |---- Cédric Klapisch"} - - {id: 232, cat: Movies, desc: " |---- Chris Marker"} - - {id: 340, cat: Movies, desc: " |---- Christophe Honoré"} - - {id: 341, cat: Movies, desc: " |---- Claire Denis"} - - {id: 392, cat: Movies, desc: " |---- Claude Berri"} - - {id: 185, cat: Movies, desc: " |---- Claude Chabrol"} - - {id: 265, cat: Movies, desc: " |---- Claude Lelouch"} - - {id: 594, cat: Movies, desc: " |---- Claude Sautet"} - - {id: 737, cat: Movies, desc: " |---- Edouard Molinaro"} - - {id: 103, cat: Movies, desc: " |---- Eric Rohmer"} - - {id: 448, cat: Movies, desc: " |---- Eugène Green"} - - {id: 129, cat: Movies, desc: " |---- François Ozon"} - - {id: 79, cat: Movies, desc: " |---- François Truffaut"} - - {id: 93, cat: Movies, desc: " |---- Gaspar Noé"} - - {id: 450, cat: Movies, desc: " |---- Georges Franju"} - - {id: 954, cat: Movies, desc: " |---- Germaine Dulac"} - - {id: 342, cat: Movies, desc: " |---- Guy Debord"} - - {id: 566, cat: Movies, desc: " |---- Henri Verneuil"} - - {id: 393, cat: Movies, desc: " |---- Henri-Georges Clouzot"} - - {id: 800, cat: Movies, desc: " |---- Jacques Audiard"} - - {id: 567, cat: Movies, desc: " |---- Jacques Becker"} - - {id: 664, cat: Movies, desc: " |---- Jacques Demy"} - - {id: 665, cat: Movies, desc: " |---- Jacques Doillon"} - - {id: 216, cat: Movies, desc: " |---- Jacques Rivette"} - - {id: 955, cat: Movies, desc: " |---- Jacques Rozier"} - - {id: 217, cat: Movies, desc: " |---- Jacques Tati"} - - {id: 511, cat: Movies, desc: " |---- Jean Becker"} - - {id: 343, cat: Movies, desc: " |---- Jean Cocteau"} - - {id: 397, cat: Movies, desc: " |---- Jean Epstein"} - - {id: 801, cat: Movies, desc: " |---- Jean Eustache"} - - {id: 802, cat: Movies, desc: " |---- Jean Grémillon"} - - {id: 183, cat: Movies, desc: " |---- Jean Renoir"} - - {id: 595, cat: Movies, desc: " |---- Jean Rollin"} - - {id: 227, cat: Movies, desc: " |---- Jean Rouch"} - - {id: 428, cat: Movies, desc: " |---- Jean Vigo"} - - {id: 449, cat: Movies, desc: " |---- Jean-Claude Brisseau"} - - {id: 956, cat: Movies, desc: " |---- Jean-Claude Rousseau"} - - {id: 267, cat: Movies, desc: " |---- Jean-Jacques Annaud"} - - {id: 344, cat: Movies, desc: " |---- Jean-Jacques Beineix"} - - {id: 43, cat: Movies, desc: " |---- Jean-Luc Godard"} - - {id: 388, cat: Movies, desc: " |---- Jean-Marie Straub e Danièle Huillet"} - - {id: 345, cat: Movies, desc: " |---- Jean-Pierre Jeunet"} - - {id: 210, cat: Movies, desc: " |---- Jean-Pierre Melville"} - - {id: 346, cat: Movies, desc: " |---- Julien Duvivier"} - - {id: 666, cat: Movies, desc: " |---- Laurent Cantet"} - - {id: 568, cat: Movies, desc: " |---- Leos Carax"} - - {id: 134, cat: Movies, desc: " |---- Louis Malle"} - - {id: 270, cat: Movies, desc: " |---- Luc Besson"} - - {id: 525, cat: Movies, desc: " |---- Luc Moullet"} - - {id: 347, cat: Movies, desc: " |---- Marcel Carné"} - - {id: 957, cat: Movies, desc: " |---- Marcel Hanoun"} - - {id: 738, cat: Movies, desc: " |---- Marcel L'Herbier "} - - {id: 451, cat: Movies, desc: " |---- Marguerite Duras"} - - {id: 348, cat: Movies, desc: " |---- Maurice Pialat"} - - {id: 349, cat: Movies, desc: " |---- Max Ophüls"} - - {id: 803, cat: Movies, desc: " |---- Mia Hansen-Love"} - - {id: 151, cat: Movies, desc: " |---- Michel Gondry"} - - {id: 350, cat: Movies, desc: " |---- Olivier Assayas"} - - {id: 667, cat: Movies, desc: " |---- Pascal Aubier"} - - {id: 224, cat: Movies, desc: " |---- Patrice Leconte"} - - {id: 873, cat: Movies, desc: " |---- Paul Vecchiali"} - - {id: 874, cat: Movies, desc: " |---- Philippe de Broca"} - - {id: 452, cat: Movies, desc: " |---- Philippe Garrel"} - - {id: 959, cat: Movies, desc: " |---- Philippe Grandrieux"} - - {id: 629, cat: Movies, desc: " |---- Pierre Étaix "} - - {id: 875, cat: Movies, desc: " |---- Pierre Granier-Deferre"} - - {id: 271, cat: Movies, desc: " |---- Raoul Ruiz"} - - {id: 226, cat: Movies, desc: " |---- René Clair"} - - {id: 429, cat: Movies, desc: " |---- René Clément"} - - {id: 109, cat: Movies, desc: " |---- Robert Bresson"} - - {id: 351, cat: Movies, desc: " |---- Robert Guédiguian"} - - {id: 398, cat: Movies, desc: " |---- Roger Vadim"} - - {id: 57, cat: Movies, desc: " |---- Roman Polanski"} - - {id: 352, cat: Movies, desc: " |---- Tony Gatlif"} - - {id: 596, cat: Movies, desc: " |---- Yves Robert"} - - {id: 139, cat: Movies, desc: " |-- Italianos"} - - {id: 526, cat: Movies, desc: " |---- Alberto Lattuada"} - - {id: 804, cat: Movies, desc: " |---- Antonio Pietrangeli"} - - {id: 58, cat: Movies, desc: " |---- Bernardo Bertolucci"} - - {id: 198, cat: Movies, desc: " |---- Dario Argento"} - - {id: 394, cat: Movies, desc: " |---- Dino Risi"} - - {id: 668, cat: Movies, desc: " |---- Elio Petri"} - - {id: 321, cat: Movies, desc: " |---- Ermanno Olmi"} - - {id: 115, cat: Movies, desc: " |---- Ettore Scola"} - - {id: 56, cat: Movies, desc: " |---- Federico Fellini"} - - {id: 597, cat: Movies, desc: " |---- Ferzan Ozpetek"} - - {id: 322, cat: Movies, desc: " |---- Francesco Rosi"} - - {id: 960, cat: Movies, desc: " |---- Franco Brocani"} - - {id: 323, cat: Movies, desc: " |---- Franco Zeffirelli"} - - {id: 569, cat: Movies, desc: " |---- Gianni Amelio"} - - {id: 98, cat: Movies, desc: " |---- Giuseppe Tornatore"} - - {id: 739, cat: Movies, desc: " |---- Liliana Cavani"} - - {id: 457, cat: Movies, desc: " |---- Lina Wertmüller"} - - {id: 68, cat: Movies, desc: " |---- Luchino Visconti"} - - {id: 805, cat: Movies, desc: " |---- Luciano Salce"} - - {id: 598, cat: Movies, desc: " |---- Lucio Fulci"} - - {id: 431, cat: Movies, desc: " |---- Luigi Comencini"} - - {id: 324, cat: Movies, desc: " |---- Marco Bellocchio"} - - {id: 325, cat: Movies, desc: " |---- Marco Ferreri"} - - {id: 197, cat: Movies, desc: " |---- Mario Bava"} - - {id: 740, cat: Movies, desc: " |---- Mario Matolli"} - - {id: 128, cat: Movies, desc: " |---- Mario Monicelli"} - - {id: 876, cat: Movies, desc: " |---- Mauro Bolognini"} - - {id: 64, cat: Movies, desc: " |---- Michelangelo Antonioni"} - - {id: 961, cat: Movies, desc: " |---- Michele Soavi"} - - {id: 174, cat: Movies, desc: " |---- Nanni Moretti"} - - {id: 669, cat: Movies, desc: " |---- Paolo Sorrentino"} - - {id: 806, cat: Movies, desc: " |---- Paolo Virzi"} - - {id: 212, cat: Movies, desc: " |---- Paolo & Vittorio Taviani"} - - {id: 80, cat: Movies, desc: " |---- Pier Paolo Pasolini"} - - {id: 527, cat: Movies, desc: " |---- Pietro Germi"} - - {id: 962, cat: Movies, desc: " |---- Raffaello Matarazzo"} - - {id: 92, cat: Movies, desc: " |---- Roberto Benigni"} - - {id: 175, cat: Movies, desc: " |---- Roberto Rossellini"} - - {id: 327, cat: Movies, desc: " |---- Sergio Corbucci"} - - {id: 112, cat: Movies, desc: " |---- Sergio Leone"} - - {id: 807, cat: Movies, desc: " |---- Sergio Martino"} - - {id: 630, cat: Movies, desc: " |---- Steno"} - - {id: 230, cat: Movies, desc: " |---- Tinto Brass"} - - {id: 326, cat: Movies, desc: " |---- Valerio Zurlini"} - - {id: 963, cat: Movies, desc: " |---- Vittorio Cottafavi"} - - {id: 127, cat: Movies, desc: " |---- Vittorio de Sica"} - - {id: 86, cat: Movies, desc: " |-- Latino-Americanos"} - - {id: 599, cat: Movies, desc: " |---- Adolfo Aristarain"} - - {id: 528, cat: Movies, desc: " |---- Adrián Caetano"} - - {id: 87, cat: Movies, desc: " |---- Alejandro Jodorowsky"} - - {id: 670, cat: Movies, desc: " |---- Andrés Wood"} - - {id: 671, cat: Movies, desc: " |---- Carlos Enrique Taboada"} - - {id: 672, cat: Movies, desc: " |---- Carlos Reygadas"} - - {id: 481, cat: Movies, desc: " |---- Carlos Sorin"} - - {id: 432, cat: Movies, desc: " |---- Daniel Burman"} - - {id: 570, cat: Movies, desc: " |---- Eliseo Subiela"} - - {id: 964, cat: Movies, desc: " |---- Emilio Fernández"} - - {id: 303, cat: Movies, desc: " |---- Fernando E. Solanas"} - - {id: 571, cat: Movies, desc: " |---- Ismael Rodriguez"} - - {id: 965, cat: Movies, desc: " |---- Leopoldo Torre Nilsson"} - - {id: 808, cat: Movies, desc: " |---- Lisandro Alonso"} - - {id: 741, cat: Movies, desc: " |---- Lucrecia Martel"} - - {id: 673, cat: Movies, desc: " |---- Marcelo Piñeyro"} - - {id: 877, cat: Movies, desc: " |---- Marco Berger"} - - {id: 674, cat: Movies, desc: " |---- Miguel Littin"} - - {id: 809, cat: Movies, desc: " |---- Pablo Larraín"} - - {id: 529, cat: Movies, desc: " |---- Pablo Trapero"} - - {id: 302, cat: Movies, desc: " |---- Santiago Álvarez"} - - {id: 572, cat: Movies, desc: " |---- Tomás Gutiérrez Alea"} - - {id: 292, cat: Movies, desc: " |-- Oceania"} - - {id: 810, cat: Movies, desc: " |---- Bruce Beresford"} - - {id: 984, cat: Movies, desc: " |---- Fred Schepisi"} - - {id: 512, cat: Movies, desc: " |---- Jane Campion"} - - {id: 966, cat: Movies, desc: " |---- Peter Jackson"} - - {id: 296, cat: Movies, desc: " |---- Peter Weir"} - - {id: 482, cat: Movies, desc: " |---- Rolf de Heer"} - - {id: 146, cat: Movies, desc: " |-- Oriente Médio"} - - {id: 147, cat: Movies, desc: " |---- Abbas Kiarostami"} - - {id: 315, cat: Movies, desc: " |---- Amos Gitai"} - - {id: 530, cat: Movies, desc: " |---- Asghar Farhadi"} - - {id: 531, cat: Movies, desc: " |---- Bahman Ghobadi"} - - {id: 878, cat: Movies, desc: " |---- Çagan Irmak"} - - {id: 675, cat: Movies, desc: " |---- Dariush Mehrjui"} - - {id: 195, cat: Movies, desc: " |---- Jafar Panahi"} - - {id: 434, cat: Movies, desc: " |---- Majid Majidi"} - - {id: 879, cat: Movies, desc: " |---- Mohammad Rasoulof"} - - {id: 172, cat: Movies, desc: " |---- Mohsen Makhmalbaf"} - - {id: 194, cat: Movies, desc: " |---- Nuri Bilge Ceylan"} - - {id: 600, cat: Movies, desc: " |---- Reha Erdem"} - - {id: 967, cat: Movies, desc: " |---- Semih Kaponoglu"} - - {id: 676, cat: Movies, desc: " |---- Yilmaz Güney"} - - {id: 513, cat: Movies, desc: " |---- Zeki Demirkubuz"} - - {id: 459, cat: Movies, desc: " |-- Península Ibérica"} - - {id: 742, cat: Movies, desc: " |---- Adolfo Arrieta"} - - {id: 968, cat: Movies, desc: " |---- Agustí Villaronga"} - - {id: 305, cat: Movies, desc: " |---- Alejandro Amenábar"} - - {id: 969, cat: Movies, desc: " |---- Albert Serra"} - - {id: 269, cat: Movies, desc: " |---- Álex de la Iglesia"} - - {id: 144, cat: Movies, desc: " |---- Bigas Luna"} - - {id: 186, cat: Movies, desc: " |---- Carlos Saura"} - - {id: 811, cat: Movies, desc: " |---- Fernando Lopes"} - - {id: 514, cat: Movies, desc: " |---- Fernando Trueba"} - - {id: 306, cat: Movies, desc: " |---- Isabel Coixet"} - - {id: 743, cat: Movies, desc: " |---- João Botelho"} - - {id: 264, cat: Movies, desc: " |---- João César Monteiro"} - - {id: 601, cat: Movies, desc: " |---- João Pedro Rodrigues"} - - {id: 812, cat: Movies, desc: " |---- José Fonseca e Costa"} - - {id: 880, cat: Movies, desc: " |---- José Luis Garci"} - - {id: 813, cat: Movies, desc: " |---- José Luis Guerín"} - - {id: 188, cat: Movies, desc: " |---- Julio Medem"} - - {id: 44, cat: Movies, desc: " |---- Luis Buñuel"} - - {id: 881, cat: Movies, desc: " |---- Luís Filipe Rocha"} - - {id: 180, cat: Movies, desc: " |---- Manoel De Oliveira"} - - {id: 814, cat: Movies, desc: " |---- Manuel Mozos"} - - {id: 677, cat: Movies, desc: " |---- Miguel Gomes"} - - {id: 970, cat: Movies, desc: " |---- Noémia Delgado"} - - {id: 815, cat: Movies, desc: " |---- Paulo Rocha"} - - {id: 55, cat: Movies, desc: " |---- Pedro Almodóvar"} - - {id: 308, cat: Movies, desc: " |---- Pedro Costa"} - - {id: 678, cat: Movies, desc: " |---- Pere Portabella"} - - {id: 971, cat: Movies, desc: " |---- Rita Azevedo Gomes"} - - {id: 972, cat: Movies, desc: " |---- Teresa Villaverde"} - - {id: 425, cat: Movies, desc: " |---- Victor Erice"} - # - {id: 6, cat: Movies, desc: "Informações em Geral"} - # - {id: 7, cat: Movies, desc: " |-- Regras e Normas"} - # - {id: 9, cat: Movies, desc: " |-- Críticas e Sugestões"} - # - {id: 22, cat: Movies, desc: " |-- Novidades"} - # - {id: 21, cat: Movies, desc: " |-- Pedidos"} - # - {id: 20, cat: Movies, desc: " |-- Boteco Making Off"} - # - {id: 54, cat: Movies, desc: " |-- Quarentena"} - # - {id: 16, cat: Movies, desc: "Tutoriais e Ajuda"} - # - {id: 17, cat: Movies, desc: " |-- Tutoriais em Geral"} - # - {id: 18, cat: Movies, desc: " |-- DivX, XviD e outros formatos"} - # - {id: 19, cat: Movies, desc: " |-- Dúvidas em Geral"} - # - {id: 145, cat: Movies, desc: " |-- O Cinema na Teoria e na Prática"} - - modes: - search: [q] - tv-search: [q, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: updated - title: title - - name: type - type: select - label: Order requested from site - default: 0 - options: - 0: desc - 1: asc - - name: info_delay - type: info - label: Delay between searches - default: This forum enforces a 20s delay between searches. If no results are returned for a search, wait for 20s and try again. - -login: - path: forum/index.php - method: form - form: form#login - inputs: - referer: https://makingoff.org/forum/index.php - ips_username: "{{ .Config.username }}" - ips_password: "{{ .Config.password }}" - rememberMe: 1 - selectorinputs: - auth_key: - selector: input[name="auth_key"] - attribute: value - error: - - selector: p.message.error - test: - path: forum/index.php - selector: a[href*="section=login&do=logout&k="] - -search: - paths: - - path: forum/index.php - inputs: - $raw: "{{ range .Categories }}search_app_filters[forums][forums][]={{.}}&{{end}}" - app: core - module: search - section: search - do: search - fromsearch: 1 - search_app: forums - search_term: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}{{ if or .Query.IMDBID .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - andor_type: and - # both, titles, content - title only search returns less information - search_content: both - # 0 posts, 1 topics - search_app_filters[forums][noPreview]: 0 - search_app_filters[forums][sortKey]: "{{ .Config.sort }}" - search_app_filters[forums][sortDir]: "{{ .Config.type }}" - submit: Pesquisar - - rows: - selector: div.post_block:has(a[href*="attach_id="]:has(strong:contains(".torrent"))) - filters: - - name: andmatch - - fields: - category: - selector: a[href*="index.php?showforum="] - attribute: href - filters: - - name: querystring - args: showforum - title: - selector: div.post.entry-content table tbody tr:nth-child(2) td div:first-child > font[size="2"] - details: - selector: a[href*="index.php?showtopic="] - attribute: href - download: - selector: a[href*="attach_id="]:has(strong:contains(".torrent")) - attribute: href - poster: - selector: div.post.entry-content table tbody tr:nth-child(4) td:first-child > span[rel="lightbox"] img.bbc_img - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: div.post.entry-content table tbody tr:nth-child(2) td:nth-child(3) > font[size="2"] - filters: - - name: regexp - args: "(?i)Tamanho: (.+?b)" - year: - selector: div.post.entry-content table tbody tr:nth-child(2) td:nth-child(2) > font[size="2"] - filters: - - name: regexp - args: "Ano de Lançamento: ((19|20)\\d{2})" - seeders: - text: 1 - leechers: - text: 1 - grabs: - selector: span:contains("ownloads") - date: - selector: abbr.published - attribute: title - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: div.post.entry-content table tbody tr:nth-child(4) td div:first-child > font[size="2"] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/malayabits-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/malayabits-api.yml deleted file mode 100644 index e3cdd43b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/malayabits-api.yml +++ /dev/null @@ -1,190 +0,0 @@ ---- -id: malayabits-api -name: Malayabits (API) -description: "Malayabits is a MALAYSIAN Private Torrent Tracker for MOVIES / TV / MUSIC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://malayabits.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Musik"} - - {id: 4, cat: Books, desc: "Book"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Malayabits account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in to the Malayabits web site with your browser once every 90 days to prevent your account from getting disabled." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 1 day regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# json UNIT3D 9.1.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/march.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/march.yml deleted file mode 100644 index 215ffe21..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/march.yml +++ /dev/null @@ -1,184 +0,0 @@ ---- -id: march -name: March -description: "March is a CHINESE Private Torrent Tracker for MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://duckboobee.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentary/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动画"} - - {id: 406, cat: Audio/Video, desc: "MV/演唱会"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 408, cat: Audio, desc: "Music/音乐"} - - {id: 409, cat: Other/Misc, desc: "Misc/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.9 2025-10-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/marinetracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/marinetracker.yml deleted file mode 100644 index b631b165..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/marinetracker.yml +++ /dev/null @@ -1,384 +0,0 @@ ---- -id: marinetracker -name: Marine Tracker -description: "Marine Tracker is a RUSSIAN Semi-Private Torrent Tracker for MARITIME E-LEARNING" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://seatracker.ru/ - -caps: - categorymappings: - - {id: 5, cat: Books, desc: "Shipbuilding & Shiprepairing"} - - {id: 314, cat: Books, desc: "Shipbuilding & Shiprepairing-Ship supply (chemicals, paints, oils)"} - - {id: 313, cat: Books, desc: "Shipbuilding & Shiprepairing-Shipbuilding materials"} - - {id: 312, cat: Books, desc: "Shipbuilding & Shiprepairing-Shipbuilding Management"} - - {id: 311, cat: Books, desc: "Shipbuilding & Shiprepairing-Mathematical Modeling"} - - {id: 310, cat: Books, desc: "Shipbuilding & Shiprepairing-Hydrodynamics"} - - {id: 309, cat: Books, desc: "Shipbuilding & Shiprepairing-Naval Architecture & Marine Engineering"} - - {id: 308, cat: Books, desc: "Shipbuilding & Shiprepairing-Shipbuilding Standard @ Register"} - - {id: 196, cat: Books, desc: "Shipbuilding & Shiprepairing-Welding"} - - {id: 195, cat: Books, desc: "Shipbuilding & Shiprepairing-Turnary"} - - {id: 21, cat: Books, desc: "Deckofficer's Library"} - - {id: 283, cat: Books, desc: "Deckofficer's Library-Radar at sea"} - - {id: 114, cat: Books, desc: "Deckofficer's Library-Watch keeping"} - - {id: 115, cat: Books, desc: "Deckofficer's Library-Keeping the ship documentation"} - - {id: 32, cat: Books, desc: "Deckofficer's Library-Cargo operations"} - - {id: 34, cat: Books, desc: "Deckofficer's Library-Navigation"} - - {id: 35, cat: Books, desc: "Deckofficer's Library-Ship handling"} - - {id: 36, cat: Books, desc: "Deckofficer's Library-Technical equipment of navigation"} - - {id: 95, cat: Books, desc: "Deckofficer's Library-Hydrometeorology"} - - {id: 100, cat: Books, desc: "Deckofficer's Library-Ship Theory"} - - {id: 110, cat: Books, desc: "Deckofficer's Library-Nautical Astronomy"} - - {id: 94, cat: Books, desc: "Deckofficer's Library-Nautical charts, tables, sailing directions"} - - {id: 116, cat: Books, desc: "Deckofficer's Library-Global Maritime Distress and Safety System (GMDSS)"} - - {id: 192, cat: Books, desc: "Deckofficer's Library-Navigation in the ice"} - - {id: 102, cat: Books, desc: "Marine Engineer's Library"} - - {id: 316, cat: Books, desc: "Marine Engineer's Library-Marine Fuels & Lubricants"} - - {id: 28, cat: Books, desc: Marine Engineer's Library-Ship's systems and mechanisms"} - - {id: 22, cat: Books, desc: Marine Engineer's Library-Daihatsu"} - - {id: 164, cat: Books, desc: Marine Engineer's Library-Operation of ship's power plants"} - - {id: 199, cat: Books, desc: Marine Engineer's Library-Ship Propulsion"} - - {id: 176, cat: Books, desc: Marine Engineer's Library-Marine refrigeration plants"} - - {id: 175, cat: Books, desc: Marine Engineer's Library-Marine boilers"} - - {id: 173, cat: Books, desc: Marine Engineer's Library-MAK & Caterpillar"} - - {id: 166, cat: Books, desc: Marine Engineer's Library-Ship's steam and gas turbines"} - - {id: 134, cat: Books, desc: Marine Engineer's Library-Marine Pumps"} - - {id: 108, cat: Books, desc: Marine Engineer's Library-Hydraulics"} - - {id: 16, cat: Books, desc: Marine Engineer's Library-MAN B&W"} - - {id: 17, cat: Books, desc: Marine Engineer's Library-Wärtsilä & Sulzer"} - - {id: 101, cat: Books, desc: "Electrical Technical Officer's Library"} - - {id: 29, cat: Books, desc: "Electrical Technical Officer's Library-Marine Electronics"} - - {id: 103, cat: Books, desc: "Electrical Technical Officer's Library-Ship's automatics"} - - {id: 104, cat: Books, desc: "Electrical Technical Officer's Library-Electrical machines"} - - {id: 210, cat: Books, desc: "Electrical Technical Officer's Library-Programmable Logic Controllers (PLC)"} - - {id: 172, cat: Books, desc: "Electrical Technical Officer's Library-Incinerators"} - - {id: 167, cat: Books, desc: "Electrical Technical Officer's Library-Cranes"} - - {id: 107, cat: Books, desc: "Electrical Technical Officer's Library-ETO Handbooks"} - - {id: 105, cat: Books, desc: "Electrical Technical Officer's Library-Sources of electricity"} - - {id: 37, cat: Books, desc: "Organization and management of maritime transport"} - - {id: 420, cat: Books, desc: "Organization and management of maritime transport-Annual reports"} - - {id: 6, cat: PC, desc: "Maritime Software"} - - {id: 211, cat: PC, desc: "Maritime Software-Navigation soft & Charts"} - - {id: 13, cat: PC, desc: "Maritime Software-Marine Tests"} - - {id: 19, cat: PC, desc: "Maritime Software-Training software"} - - {id: 20, cat: PC, desc: "Maritime Software-Calculation soft"} - - {id: 14, cat: PC, desc: "Maritime Software-Maritime Simulators"} - - {id: 376, cat: PC, desc: "Maritime Software-Games"} - - {id: 179, cat: PC, desc: "Maritime Software-Programming and SDK software"} - - {id: 317, cat: PC, desc: "Maritime Software-Shareware (demo, trial)"} - - {id: 188, cat: PC, desc: "Maritime Software-Other soft"} - - {id: 226, cat: Books, desc: "Mobile Apps for Seafarers"} - - {id: 380, cat: PC, desc: "Mobile Apps for Seafarers-Apps for Deck Officers"} - - {id: 381, cat: PC, desc: "Mobile Apps for Seafarers-Apps for Marine Engineers"} - - {id: 383, cat: PC, desc: "Mobile Apps for Seafarers-Exam Apps &Test Answers"} - - {id: 384, cat: PC, desc: "Mobile Apps for Seafarers-Foreign languages learning"} - - {id: 382, cat: PC, desc: "Mobile Apps for Seafarers-Other mobile Apps"} - - {id: 8, cat: Movies, desc: "Video for Seamen"} - - {id: 400, cat: Movies, desc: "Video for Seamen-Maritime Law & Regulations videos"} - - {id: 399, cat: Movies, desc: "Video for Seamen-Maritime Shipping & Cargo Operations videos"} - - {id: 398, cat: Movies, desc: "Video for Seamen-Offshore Industry videos"} - - {id: 397, cat: Movies, desc: "Video for Seamen-Maritime Safety videos"} - - {id: 396, cat: Movies, desc: "Video for Seamen-Maritime History videos"} - - {id: 395, cat: Movies, desc: "Video for Seamen-Shipbuilding & Shiprepairing videos"} - - {id: 394, cat: Movies, desc: "Video for Seamen-Ship Modeling & RC Boat videos"} - - {id: 237, cat: Movies, desc: "Video for Seamen-Yachting, sailing videos"} - - {id: 377, cat: Movies, desc: "Video for Seamen-Documentary video"} - - {id: 234, cat: Movies, desc: "Video for Seamen-Video for Deck Officers"} - - {id: 233, cat: Movies, desc: "Video for Seamen-Video for Marine Engineers"} - - {id: 235, cat: Movies, desc: "Video for Seamen-Other videos"} - - {id: 42, cat: Books, desc: "Safety of Navigation"} - - {id: 307, cat: Books, desc: "Safety of Navigation-Life Safety at Sea"} - - {id: 306, cat: Books, desc: "Safety of Navigation-Casualties and Incidents"} - - {id: 305, cat: Books, desc: "Safety of Navigation-Safety Digest, Lessons from Marine Accident Reports"} - - {id: 304, cat: Books, desc: "Safety of Navigation-Fire fighting at sea"} - - {id: 303, cat: Books, desc: "Safety of Navigation-Anti-Piracy Security"} - - {id: 302, cat: Books, desc: "Safety of Navigation-Emergency and life-saving equipment"} - - {id: 301, cat: Books, desc: "Safety of Navigation-Cybersecurity"} - - {id: 10, cat: Books, desc: "English for seamen"} - - {id: 419, cat: Books, desc: "English for seamen-Marine multilingual dictionaries"} - - {id: 270, cat: Books, desc: "English for seamen-EN↔RU dictionaries & phrasebooks"} - - {id: 417, cat: Books, desc: "English for seamen-Dictionaries & phrasebooks (other langs"} - - {id: 265, cat: Books, desc: "English for seamen-Marine monolingual dictionaries and glossaries EN↔EN"} - - {id: 416, cat: Books, desc: "English for seamen-Marine monolingual dictionaries and glossaries RU↔RU"} - - {id: 418, cat: Books, desc: "English for seamen-Marine monolingual dictionaries and glossaries…"} - - {id: 387, cat: Books, desc: "English for seamen-English for Radio Operators"} - - {id: 266, cat: Books, desc: "English for seamen-English for Deck Officers"} - - {id: 267, cat: Books, desc: "English for seamen-English for Marine Engineers"} - - {id: 268, cat: Books, desc: "English for seamen-English for ETO's"} - - {id: 269, cat: Books, desc: "English for seamen-English for Shipbuilders"} - - {id: 388, cat: Books, desc: "English for seamen-English for Yachtsmen"} - - {id: 271, cat: Books, desc: "English for seamen-Business English and Correspondence"} - - {id: 205, cat: Books, desc: "General english"} - - {id: 39, cat: Books, desc: "Purpose-built ships"} - - {id: 250, cat: Books, desc: "Purpose-built ships-LNG \ LPG - CNG"} - - {id: 51, cat: Books, desc: "Purpose-built ships-Oil & Chemical Tankers"} - - {id: 52, cat: Books, desc: "Purpose-built ships-Dry cargo vessels"} - - {id: 284, cat: Books, desc: "Purpose-built ships-Nuclear powered ships"} - - {id: 54, cat: Books, desc: "Purpose-built ships-Passenger ships"} - - {id: 385, cat: Books, desc: "Purpose-built ships-Towing vessels"} - - {id: 248, cat: Books, desc: "Purpose-built ships-Fishing vessels"} - - {id: 239, cat: Books, desc: "Purpose-built ships-Offshore"} - - {id: 349, cat: Books, desc: "Purpose-built ships-Dynamic positioning vessels"} - - {id: 386, cat: Books, desc: "Purpose-built ships-Submarine vehicles"} - - {id: 212, cat: Books, desc: "Ship's documentation"} - - {id: 190, cat: Books, desc: "Catalogs"} - - {id: 191, cat: Books, desc: "Maritime Law"} - - {id: 240, cat: Books, desc: "Maritime Law-IMO Resolutions"} - - {id: 329, cat: Books, desc: "Maritime Law-Lloyd's, ABS, BV, DNV-GL, TÜV, NKK, RMRS and other approvals"} - - {id: 113, cat: Books, desc: "Medicine"} - - {id: 391, cat: Books, desc: "Sailing First Aid"} - - {id: 390, cat: Books, desc: "Sailing Infectious Diseases and Viruses"} - - {id: 392, cat: Books, desc: "Sailing English for Medical Professionals"} - - {id: 393, cat: Books, desc: "Sailing Mental health & Stress"} - - {id: 299, cat: Books, desc: "Sailing Kitchen"} - - {id: 11, cat: Books, desc: "Section for cadets"} - - {id: 403, cat: Books, desc: "Section for cadets-Textbooks and Guides"} - - {id: 406, cat: Books, desc: "Section for cadets-Reference Materials"} - - {id: 408, cat: Books, desc: "Section for cadets-Practical Training, RB and Reports"} - - {id: 402, cat: Books, desc: "Section for cadets-Methodical Materials"} - - {id: 404, cat: Books, desc: "Section for cadets-Term Papers"} - - {id: 407, cat: Books, desc: "Section for cadets-Laboratory Works"} - - {id: 401, cat: Books, desc: "Section for cadets-Lectures and Notes"} - - {id: 410, cat: Books, desc: "Section for cadets-Cheat Sheets"} - - {id: 405, cat: Books, desc: "Section for cadets-Charts and Atlases"} - - {id: 285, cat: Books, desc: "Section for cadets-Encyclopedias and atlases"} - - {id: 409, cat: Books, desc: "Section for cadets-Young Skipper"} - - {id: 413, cat: Books, desc: "Section for cadets-For the Little Ones"} - - {id: 330, cat: Books, desc: "Examinations"} - - {id: 12, cat: Books, desc: "Examinations-Answers to tests"} - - {id: 333, cat: Books, desc: "Examinations-IMU CET (Indian Maritime University Common Entrance Test)"} - - {id: 334, cat: Books, desc: "Examinations-NCV (Near Coastal Voyages)"} - - {id: 336, cat: Books, desc: "Examinations-ASM (Master)"} - - {id: 337, cat: Books, desc: "Examinations-TME (Trainee Marine Engineer)"} - - {id: 338, cat: Books, desc: "Examinations-MEO (Maritime Engineering Officer)"} - - {id: 341, cat: Books, desc: "Examinations-Chief Mates (Phase I) & Orals"} - - {id: 343, cat: Books, desc: "Examinations-ROR - Colregs"} - - {id: 335, cat: Books, desc: "Examinations-Second Mates"} - - {id: 342, cat: Books, desc: "Examinations-Chief Mates (Phase II)"} - - {id: 340, cat: Books, desc: "Examinations-Text books"} - - {id: 339, cat: Books, desc: "Examinations-Miscellaneous"} - - {id: 99, cat: Audio, desc: "Audio"} - - {id: 287, cat: Audio/Audiobook, desc: "Audio-Non-Fiction"} - - {id: 373, cat: Audio/Audiobook, desc: "Audio-Fiction"} - - {id: 288, cat: Audio, desc: "Audio-♬ Music"} - - {id: 111, cat: Books, desc: "Yachting, sailing, boating"} - - {id: 389, cat: Books, desc: "Yachting, sailing, boating-Sailboat terminology"} - - {id: 345, cat: Books, desc: "Yachting, sailing, boating-Solar boats"} - - {id: 318, cat: Books, desc: "Yachting, sailing, boating-Sailing life"} - - {id: 300, cat: Books, desc: "Yachting, sailing, boating-RYA"} - - {id: 298, cat: Books, desc: "Yachting, sailing, boating-Cruising Guides"} - - {id: 297, cat: Books, desc: "Yachting, sailing, boating-Weather Sailing"} - - {id: 296, cat: Books, desc: "Yachting, sailing, boating-How to Buy a Boat?"} - - {id: 258, cat: Books, desc: "Yachting, sailing, boating-Inflatable boats, boats, kayaks"} - - {id: 257, cat: Books, desc: "Yachting, sailing, boating-Motor Boat & Yachting magazines"} - - {id: 253, cat: Books, desc: "Yachting, sailing, boating-Seamanship practice"} - - {id: 254, cat: Books, desc: "Yachting, sailing, boating-Repair and Restoration"} - - {id: 255, cat: Books, desc: "Yachting, sailing, boating-Sailing Alone"} - - {id: 256, cat: Books, desc: "Yachting, sailing, boating-Rigging, sails, knots"} - - {id: 252, cat: Books, desc: "Yachting, sailing, boating-Projects, design, construction"} - - {id: 251, cat: Books, desc: "Diving"} - - {id: 177, cat: Books, desc: "Shipmodeling"} - - {id: 352, cat: Books, desc: "Shipmodeling-Paper models"} - - {id: 293, cat: Books, desc: "Shipmodeling-Radio controlled models"} - - {id: 292, cat: Books, desc: "Shipmodeling-Marine Modelling Magazines"} - - {id: 291, cat: Books, desc: "Shipmodeling-Drawings and models of ships (CAD, 3D)"} - - {id: 346, cat: Books, desc: "Shipmodeling-Raster drawings (JPG, PDF, TIFF)"} - - {id: 290, cat: Books, desc: "Shipmodeling-Reference Material"} - - {id: 289, cat: Books, desc: "Shipmodeling-Ships in Bottles"} - - {id: 197, cat: Books, desc: "Marine bulletins"} - - {id: 219, cat: Books, desc: "Marine History"} - - {id: 347, cat: Books, desc: "Marine History-Sea Life"} - - {id: 281, cat: Books, desc: "Marine History-History of Merchant Marine"} - - {id: 259, cat: Books, desc: "Marine History-Shipbuilding history"} - - {id: 273, cat: Books, desc: "Marine History-History of Seafaring"} - - {id: 275, cat: Books, desc: "Marine History-Accidents and disasters at sea"} - - {id: 264, cat: Books, desc: "Marine History-Marine cartography"} - - {id: 274, cat: Books, desc: "Marine History-Pirates"} - - {id: 282, cat: Books, desc: "Fleets history"} - - {id: 370, cat: Books, desc: "Fleets history-Maritime museums of the world"} - - {id: 357, cat: Books, desc: "Fleets history-China"} - - {id: 354, cat: Books, desc: "Fleets history-USA"} - - {id: 356, cat: Books, desc: "Fleets history-Russia | USSR"} - - {id: 358, cat: Books, desc: "Fleets history-India"} - - {id: 355, cat: Books, desc: "Fleets history-Japan"} - - {id: 364, cat: Books, desc: "Fleets history-The United Kingdom"} - - {id: 359, cat: Books, desc: "Fleets history-Germany"} - - {id: 360, cat: Books, desc: "Fleets history-France"} - - {id: 368, cat: Books, desc: "Fleets history-Netherlands"} - - {id: 369, cat: Books, desc: "Fleets history-Spain"} - - {id: 277, cat: Books, desc: "Wars at Sea"} - - {id: 276, cat: Books, desc: "Wars at Sea-War at sea (World War I)"} - - {id: 261, cat: Books, desc: "Wars at Sea-War at sea (World War II)"} - - {id: 363, cat: Books, desc: "Wars at Sea-Russo-Japanese War"} - - {id: 362, cat: Books, desc: "History of Naval Shipbuilding"} - - {id: 279, cat: Books, desc: "History of Naval Shipbuilding-Marine vessels"} - - {id: 280, cat: Books, desc: "History of Naval Shipbuilding-Historic ships"} - - {id: 278, cat: Books, desc: "History of Naval Shipbuilding-Warships"} - - {id: 351, cat: Books, desc: "History of Naval Shipbuilding-Battleships"} - - {id: 353, cat: Books, desc: "History of Naval Shipbuilding-Cruisers"} - - {id: 365, cat: Books, desc: "History of Naval Shipbuilding-Aircraft carriers"} - - {id: 366, cat: Books, desc: "History of Naval Shipbuilding-Destroyers"} - - {id: 367, cat: Books, desc: "History of Naval Shipbuilding-Frigates"} - - {id: 371, cat: Books, desc: "History of Naval Shipbuilding-Combat boats"} - - {id: 372, cat: Books, desc: "History of Naval Shipbuilding-Coastal defense ships"} - - {id: 262, cat: Books, desc: "History of Naval Shipbuilding-The history of sailing ships"} - - {id: 323, cat: Books, desc: "History of Naval Shipbuilding-History of Submarines"} - - {id: 198, cat: Books, desc: "Navy"} - - {id: 260, cat: Books, desc: "Navy-Naval shipbuilding (1950 - 20??)"} - - {id: 272, cat: Books, desc: "Navy-Naval magazines"} - - {id: 294, cat: Books, desc: "Environmental protection"} - - {id: 242, cat: Books, desc: "Foreign language books"} - - {id: 344, cat: Books, desc: "Foreign language books-Yachting"} - - {id: 218, cat: Books, desc: "Maritime Fiction"} - - {id: 350, cat: Other, desc: "Art"} - - {id: 243, cat: Books, desc: "Rarity"} - - {id: 244, cat: Books, desc: "Rarity-1900 - 1950"} - - {id: 245, cat: Books, desc: "Rarity-1800 - 1900"} - - {id: 246, cat: Books, desc: "Rarity-1700 - 1800"} - - {id: 247, cat: Books, desc: "Rarity-1600 - 1700"} - - {id: 414, cat: Books, desc: "Rarity-1500 - 1600"} - - {id: 415, cat: Books, desc: "Rarity-before 1500"} - - {id: 324, cat: Other, desc: "Posters, placards, signs"} - - {id: 220, cat: Books, desc: "Fishing & Spearfishing"} - - {id: 319, cat: Books, desc: "Fishing tackle and knots"} - - {id: 412, cat: Books, desc: "Marine Fauna"} - - {id: 7, cat: Books, desc: "Technical Books (Not maritime)"} - - {id: 328, cat: Books, desc: "Technical Books (Not maritime)-Hydraulics"} - - {id: 327, cat: Books, desc: "Technical Books (Not maritime)-Mechanics"} - - {id: 326, cat: Books, desc: "Technical Books (Not maritime)-Electricity"} - - {id: 187, cat: Books, desc: "Others (not maritime)"} - - {id: 411, cat: Books, desc: "Others (not maritime)-Video"} - - {id: 38, cat: Books, desc: "Uncategorized"} - - {id: 162, cat: Books, desc: "Archive"} - - {id: 321, cat: Books, desc: "Archive-Outdated software"} - - {id: 379, cat: Books, desc: "Archive-Video materials (Archive)"} - - {id: 378, cat: Books, desc: "Archive-Audio materials (Archive) "} - - {id: 322, cat: Books, desc: "Archive-No seeds"} -# - {id: 325, cat: Other, desc: "Corrupt torrents (hash errors)"} # we filter these out in the rows selector - - {id: 295, cat: Books, desc: "Archive-Unregistered"} - - modes: - search: [q] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech_50 - type: checkbox - label: Search freeleech and 50% freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: / - method: form - form: form.logform - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - autologin: 1 - error: - - selector: div#error_fon - test: - path: / - selector: a[href^="/./login.php?logout="] - -search: - paths: - - path: tracker.php - method: post - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - nm: "{{ .Keywords }}" - allw: 1 - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tm: -1 - sns: -1 - prev_allw: 1 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_dl: 1 - prev_ds: 0 - prev_tor_type: 0 - df: 1 - da: 1 - dl: 1 - tor_type: "{{ if .Config.freeleech_50 }}1{{ else }}{{ end }}" - submit: "%C2%A0%C2%A0Search%C2%A0%C2%A0" - - rows: - selector: tr[id^="tor_"]:has(a[href^="./download.php?id="]):not(:has(a[href="tracker.php?f=325"])) - - fields: - title: - selector: a.tLink - details: - selector: a.tLink - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - category: - selector: td a.gen - attribute: href - filters: - - name: querystring - args: f - description: - selector: td span.iconDL - size: - selector: td:nth-last-child(5) > u - optional: true - default: 0 B - seeders: - selector: td.seedmed - leechers: - selector: td.leechmed - grabs: - selector: td:nth-last-child(2) - date: - selector: td:last-child > u - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - img[src="images/tor_silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 -# Engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/matrix.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/matrix.yml deleted file mode 100644 index d256cbf4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/matrix.yml +++ /dev/null @@ -1,219 +0,0 @@ ---- -id: matrix -name: Matrix -description: "M.A.T.R.I.X is a HUNGARIAN Private Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://matrixworld.info/ - -caps: - categorymappings: - # cats commented out have no hits in results so image to use in category case block cannot be loaded for matching - # also note: 43 & 44 have the same pic, as does 60 & 61 - - {id: 23, cat: Movies/Other, desc: "Animációk"} -# - {id: 61, cat: Movies/Other, desc: "Cam/EN"} - - {id: 60, cat: Movies/Other, desc: "Cam/HU"} - - {id: 45, cat: Books/EBook, desc: "eBook/EN"} - - {id: 40, cat: Books/EBook, desc: "eBook/HU"} - - {id: 20, cat: Movies/DVD, desc: "Film/DVD9"} - - {id: 48, cat: Movies/HD, desc: "Film/EN/1080p"} - - {id: 32, cat: Movies/HD, desc: "Film/EN/720p"} - - {id: 38, cat: Movies/BluRay, desc: "Film/EN/Blu-ray"} - - {id: 27, cat: Movies/DVD, desc: "Film/EN/DVD-R"} - - {id: 24, cat: Movies/SD, desc: "Film/EN/SD"} - - {id: 34, cat: Movies/HD, desc: "Film/HU/1080p"} - - {id: 31, cat: Movies/HD, desc: "Film/HU/720p"} - - {id: 35, cat: Movies/BluRay, desc: "Film/HU/Blu-ray"} - - {id: 26, cat: Movies/DVD, desc: "Film/HU/DVD-R"} - - {id: 5, cat: Movies/SD, desc: "Film/HU/SD"} - - {id: 62, cat: Movies/UHD, desc: "Film/HU/UHD"} - - {id: 4, cat: PC/Games, desc: "Játékok/ISO"} - - {id: 39, cat: PC/Games, desc: "Játékok/Rip/Dox"} - - {id: 47, cat: Other, desc: "Klippek"} - - {id: 30, cat: Audio/Lossless, desc: "Lossless/EN"} - - {id: 29, cat: Audio/Lossless, desc: "Lossless/HU"} - - {id: 25, cat: Audio/MP3, desc: "MP3/EN"} - - {id: 6, cat: Audio/MP3, desc: "MP3/HU"} - - {id: 33, cat: PC, desc: "Program/egyéb"} - - {id: 1, cat: PC/ISO, desc: "Program/ISO"} - - {id: 36, cat: TV/HD, desc: "Sorozat/EN/HD"} - - {id: 49, cat: TV/SD, desc: "Sorozat/EN/SD"} - - {id: 28, cat: TV/HD, desc: "Sorozat/HU/HD"} - - {id: 7, cat: TV/SD, desc: "Sorozat/HU/SD"} - - {id: 9, cat: XXX, desc: "XXX"} - - {id: 44, cat: XXX/x264, desc: "XXX/HD"} -# - {id: 43, cat: XXX/SD, desc: "XXX/SD"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - book-search: [q] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: ctime - options: - ctime: created - name: title - size: size - seeders: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "If you do not log in to the site for 6 months, the system will automatically delete it!" - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href*="logout.php?logout="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active only, 1 include dead, 2 only dead, 3 free, 4 double - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - rows: - selector: table.table-center[width="905"] > tbody > tr:has(a[onclick]) - - fields: - category: - case: - img[src="/pic/categories/cat_anime.gif"]: 23 -# img[src="/pic/categories/061.png"]: 61 - img[src="/pic/categories/061.png"]: 60 - img[src="/pic/categories/017.png"]: 45 - img[src="/pic/categories/016.png"]: 40 - img[src="/pic/categories/cat_movies.gif"]: 20 - img[src="/pic/categories/010.png"]: 48 - img[src="/pic/categories/9.png"]: 32 - img[src="/pic/categories/011.png"]: 38 - img[src="/pic/categories/8.png"]: 27 - img[src="/pic/categories/7.png"]: 24 - img[src="/pic/categories/4.png"]: 34 - img[src="/pic/categories/3.png"]: 31 - img[src="/pic/categories/5.png"]: 35 - img[src="/pic/categories/2.png"]: 26 - img[src="/pic/categories/1.png"]: 5 - img[src="/pic/categories/uhd_hun.png"]: 62 - img[src="/pic/categories/021.png"]: 4 - img[src="/pic/categories/cat_games.gif"]: 39 - img[src="/pic/categories/026.png"]: 47 - img[src="/pic/categories/024.png"]: 30 - img[src="/pic/categories/019.png" ]: 29 - img[src="/pic/categories/023.png"]: 25 - img[src="/pic/categories/018.png"]: 6 - img[src="/pic/categories/025.png"]: 33 - img[src="/pic/categories/020.png"]: 1 - img[src="/pic/categories/015.png"]: 36 - img[src="/pic/categories/014.png"]: 49 - img[src="/pic/categories/013.png"]: 28 - img[src="/pic/categories/012.png"]: 7 - img[src="/pic/categories/cat_xxx.gif"]: 9 - img[src="/pic/categories/6.png"]: 44 -# img[src="/pic/categories/6.png"]: 43 - title: - selector: a[onclick] - _id: - selector: a[onclick] - attribute: onclick - filters: - - name: regexp - args: toggleRow\((\d+), - details: - text: "details.php?id={{ .Result._id }}" - download: - text: "download.php?id={{ .Result._id }}" - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: \\\'(.+?)\\ - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(4) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - _verified: - selector: span.torrent-ok, span.torrent-unchecked - case: - span.torrent-ok: Verified - span.torrent-unchecked: Unchecked - genre: - selector: td:nth-child(2) > div > i.small - filters: - - name: regexp - args: "\\((.+)\\)" - description: - text: "{{ .Result._verified }} - {{ .Result.genre }}" - downloadvolumefactor: - case: - span.text-red:contains("[x0]"): 0 - "*": 1 - uploadvolumefactor: - case: - span.text-blue:contains("[x2]"): 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TBDev & XBTT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mazepa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mazepa.yml deleted file mode 100644 index b1ca4bf4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mazepa.yml +++ /dev/null @@ -1,348 +0,0 @@ ---- -id: mazepa -name: Mazepa -description: "Mazepa is an UKRAINIAN Semi-Private Torrent Tracker for Videos with Ukrainian soundtracks" -language: uk-UA -type: semi-private -encoding: UTF-8 -links: - - https://mazepa.to/ - -caps: - categorymappings: - # note: when refreshing cats use the tracker.php not the search.php! - # Колекція українського контенту - - {id: 37, cat: Movies/UHD, desc: "Українські фільми HD, UHD"} - - {id: 7, cat: Movies/SD, desc: "Українські фільми SD"} - - {id: 38, cat: TV/UHD, desc: "Українські серіали HD, UHD"} - - {id: 8, cat: TV/SD, desc: "Українські серіали SD"} - - {id: 35, cat: Movies/UHD, desc: "Українські мультфільми HD, UHD"} - - {id: 5, cat: Movies/SD, desc: "Українські мультфільми SD"} - - {id: 36, cat: TV/UHD, desc: "Українські мультсеріали HD, UHD"} - - {id: 6, cat: TV/SD, desc: "Українські мультсеріали SD"} - - {id: 39, cat: TV/Documentary, desc: "Українські документальні HD, UHD"} - - {id: 9, cat: TV/Documentary, desc: "Українські документальні SD"} - # Озвучений контент - - {id: 175, cat: Movies/UHD, desc: "Озвучений контент Новинки фільмів HD, UHD"} - - {id: 147, cat: Movies/UHD, desc: "Озвучений контент Фільми UHD"} - - {id: 12, cat: Movies/HD, desc: "Озвучений контент Фільми HD"} - - {id: 13, cat: Movies/SD, desc: "Озвучений контент Фільми SD"} - - {id: 174, cat: Movies, desc: "Озвучений контент Субтитровані фільми"} - - {id: 152, cat: TV/UHD, desc: "Озвучений контент Серіали UHD"} - - {id: 44, cat: TV/HD, desc: "Озвучений контент Серіали HD"} - - {id: 14, cat: TV/SD, desc: "Озвучений контент Серіали SD"} - - {id: 155, cat: Movies/UHD, desc: "Озвучений контент Мультфільми UHD"} - - {id: 41, cat: Movies/HD, desc: "Озвучений контент Мультфільми HD"} - - {id: 10, cat: Movies/SD, desc: "Озвучений контент Мультфільми SD"} - - {id: 43, cat: TV/HD, desc: "Озвучений контент Мультсеріали HD"} - - {id: 11, cat: TV/SD, desc: "Озвучений контент Мультсеріали SD"} - - {id: 16, cat: TV/Anime, desc: "Аніме"} - - {id: 157, cat: TV/Documentary, desc: "Озвучений Документальне UHD"} - - {id: 42, cat: TV/Documentary, desc: "Озвучений Документальне HD"} - - {id: 15, cat: TV/Documentary, desc: "Озвучений Документальне SD"} - # Спорт - - {id: 20, cat: TV/Sport, desc: "Спорт Формула 1 Сезон 2025"} - - {id: 167, cat: TV/Sport, desc: "Спорт Формула 1 Сезон 2022-2024"} - - {id: 79, cat: TV/Sport, desc: "Спорт Формула 1 Сезони 2017-2021"} - - {id: 21, cat: TV/Sport, desc: "Спорт Формула 1 Сезони 2007-2016"} - - {id: 75, cat: TV/Sport, desc: "Спорт Формула 1 Сезони до 2006"} - - {id: 77, cat: TV/Sport, desc: "Автоспорт"} - - {id: 47, cat: TV/Sport, desc: "Автоспорт Чемпіонат та кубок України"} - - {id: 46, cat: TV/Sport, desc: "Автоспорт Єврокубки"} - - {id: 48, cat: TV/Sport, desc: "Автоспорт Чемпіонат Світу"} - - {id: 182, cat: TV/Sport, desc: "Автоспорт Євро 2024"} - - {id: 49, cat: TV/Sport, desc: "Автоспорт Чемпіонат Європи"} - - {id: 53, cat: TV/Sport, desc: "Автоспорт Закордонні чемпіонати"} - - {id: 19, cat: TV/Sport, desc: "Бокс"} - # Телевізійні передачі - - {id: 29, cat: Audio/Video, desc: "Концерти, відеокліпи"} - - {id: 30, cat: TV, desc: "Теле-Шоу"} - # Музика - - {id: 65, cat: Audio, desc: "Музика Рок"} - - {id: 63, cat: Audio, desc: "Музика Поп, Диско"} - - {id: 66, cat: Audio, desc: "Музика Фольк, Етно, Народна, Бардівська"} - - {id: 64, cat: Audio, desc: "Музика Реп"} - - {id: 67, cat: Audio, desc: "Музика Електронна"} - - {id: 61, cat: Audio, desc: "Музика Джаз, Блюз"} - - {id: 60, cat: Audio, desc: "Музика Класична, Інструментальна"} - - {id: 82, cat: Audio, desc: "Музика Невидане"} - # Література - - {id: 93, cat: Books, desc: "Література Українська художня література [до 1991 р.]"} - - {id: 92, cat: Books, desc: "Література Українська художня література (після 1991 р.)"} - - {id: 91, cat: Books, desc: "Література Зарубіжна художня література"} - - {id: 90, cat: Books, desc: "Література Наукова література (гуманітарні дисципліни)"} - - {id: 89, cat: Books, desc: "Література Наукова література (природничі дисципліни)"} - - {id: 88, cat: Books, desc: "Література Навчальна та довідкова"} - - {id: 87, cat: Books, desc: "Література Періодика"} - - {id: 86, cat: Books, desc: "Література Батькам та малятам"} - - {id: 85, cat: Books/Comics, desc: "Література Графіка (комікси, манґа, BD та інше)"} - - {id: 84, cat: Audio/Audiobook, desc: "Аудіокниги українською"} - - {id: 96, cat: Audio/Audiobook, desc: "Аудіокниги українською Українська художня література"} - - {id: 95, cat: Audio/Audiobook, desc: "Аудіокниги українською Зарубіжна художня література"} - - {id: 94, cat: Audio/Audiobook, desc: "Аудіокниги українською Історія, біографістика, спогади"} - # Програмне забезпечення - - {id: 168, cat: PC, desc: "Програмне забезпечення Операційні системи"} - - {id: 169, cat: PC, desc: "Програмне забезпечення Системні програми"} - - {id: 170, cat: PC, desc: "Програмне забезпечення Офіс, текстові редактори"} - - {id: 171, cat: PC, desc: "Програмне забезпечення Аудіо, відео обробка"} - - {id: 173, cat: PC, desc: "Програмне забезпечення Інше"} - - {id: 185, cat: PC, desc: "Програмне забезпечення Ігри"} - # archive, not in tracker search category pulldown - - {id: 23, cat: Other, desc: "Архів"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: login.php - method: post - inputs: - "login_username": "{{ .Config.username }}" - "login_password": "{{ .Config.password }}" - autologin: 1 - login: "Увійти" - error: - - selector: table.error - test: - path: index.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - - path: tracker.php - method: post - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 1 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - prev_tor_type: 0 - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # show speed - ds: 0 - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - # group releases -1=not selected - srg: -1 - nm: "{{ .Keywords }}" - # all words - allw: 1 - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E01 to сері 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сері $1"] - - name: re_replace # S01E01 to сезон 1 сері 1 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сері $2"] - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: tr[id^="tor_"]:has(a[href^="./dl.php?id="]) - - fields: - category: - selector: a[onclick] - attribute: onclick - filters: - - name: regexp - args: f=(\d+) - title: - selector: a.genmed, a.seedmed, a.leechmed - filters: - # normalize to SXXEYY format - # Ukrainian - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езони?.+?(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езони?.+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ері[їяй]|Епізоди?)\\s+з\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ері[їяй]|Епізоди?)\\s+з\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езони?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езони?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езони?", "S$1"] - - name: re_replace - args: ["(?i)(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*з\\s*(\\w?)(?:\\s*(?:[CС]ері[їяй]|Епізоди?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ері[їяй]|Епізоди?)\\s+з\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:[CС]ері[їяй]|Епізоди?)[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ері[їяй]|Епізоди?)", "E$1"] - # English - - name: re_replace - args: ["(?i)Seasons?[\\s:]*(\\d+(?:-\\d+)?).+?(?:Episodes?)[\\s:]*(\\d+(?:-\\d+)?)\\s*of\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*Seasons?.+?(\\d+(?:-\\d+)?)\\s*of\\s*(\\w?)(?:\\s*Episodes?)?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*Seasons?.+?(\\d+(?:-\\d+)?)\\s*(?:Episodes?)\\s+of\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)Seasons?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*of\\s*(\\w?)(?:\\s*Episodes?)?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)Seasons?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:Episodes?)\\s+of\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)Seasons?[\\s:]*(\\d+(?:-\\d+)?).+?(?:Episodes?)[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+Seasons?.+?(\\d+(?:-\\d+)?)\\s+(?:\\s*Episodes?)", "S$1E$2"] - - name: re_replace - args: ["(?i)Seasons?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*Episodes?)", "S$1E$2"] - - name: re_replace - args: ["(?i)Seasons?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+Seasons?", "S$1"] - - name: re_replace - args: ["(?i)(?:Episodes?)[\\s:]*(\\d+(?:-\\d+)?)\\s*of\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:Episodes?)\\s+of\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:Episodes?)[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:Episodes?)", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - details: - selector: a.genmed, a.seedmed, a.leechmed - attribute: href - download: - selector: a[href^="./dl.php?id="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: a[href^="./dl.php?id="] - seeders: - selector: td.seedmed - leechers: - selector: td.leechmed - grabs: - selector: td:nth-last-child(2) - date: - # 11:51 31-Жов-23 - # Вчора 00:04 3-Лис-23 - # Сьогодні 06:40 4-Лис-23 - selector: td:last-child - filters: - - name: regexp - args: (\d{2}:\d{2}\s+\d{1,2}-\D{3}-\d{2}) - - name: re_replace - args: ["\\s", ""] - - name: re_replace - args: ["(?i)Січ", "Jan"] - - name: re_replace - args: ["(?i)Лют", "Feb"] - - name: re_replace - args: ["(?i)Бер", "Mar"] - - name: re_replace - args: ["(?i)Кві", "Apr"] - - name: re_replace - args: ["(?i)Тра", "May"] - - name: re_replace - args: ["(?i)Чер", "Jun"] - - name: re_replace - args: ["(?i)Лип", "Jul"] - - name: re_replace - args: ["(?i)Сер", "Aug"] - - name: re_replace - args: ["(?i)Вер", "Sep"] - - name: re_replace - args: ["(?i)Жов", "Oct"] - - name: re_replace - args: ["(?i)Лис", "Nov"] - - name: re_replace - args: ["(?i)Гру", "Dec"] - - name: append - args: " +02:00" # EET - - name: dateparse - args: "HH:mmd-MMM-yy zzz" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - case: - span.tor-approved: Verified - span.tor-not-approved: Unverified - span.tor-dup: Temporary -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mdan.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mdan.yml deleted file mode 100644 index 8098a260..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mdan.yml +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: mdan -name: MDAN -description: "Mansão dos Animes (MDAN) is a BRAZILIAN Private Torrent Tracker for ANIME" -language: pt-BR -type: private -encoding: UTF-8 -links: - - https://bt.mdan.org/ - -caps: - categorymappings: - - {id: 5, cat: TV/Anime, desc: "Animes Completo"} - - {id: 1, cat: TV/Anime, desc: "Animes Episódios"} - - {id: 2, cat: TV/Anime, desc: "Animes OVAs"} - - {id: 3, cat: Movies, desc: "Filmes"} - - {id: 6, cat: Movies/Other, desc: "Live"} - - {id: 7, cat: Books/Comics, desc: "Mangá"} - - {id: 4, cat: Audio, desc: "OST"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents por página (Torrents per Page): setting to 100 on your Perfil (Profile). The default is 30. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts are automatically deleted." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: :contains("incorret") - - selector: :root:contains("Too many users. Please") - test: - path: index.php - selector: a[href^="logout.php?hash_please="] - -search: - paths: - - path: browse.php - inputs: - # note: site uses the catsX[]=nn method which cardigann does not support. - search: "{{ .Keywords }}" - # title, descr, genre, all - searchin: title - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 no, 1 yes - only_free: "{{ if .Config.freeleech }}1{{else}}{{end}}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[style^="width"] > tbody > tr:has(a[href^="details.php?id="]), table[style^="width"] > tbody > tr[id^="kdescr"] - after: 1 - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - # titles are abbreviated :-( - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - _id: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: querystring - args: id - download: - text: "download.php?torrent={{ .Result._id }}&ssl=1" - poster: - selector: td:nth-child(9) img - attribute: src - date_day: - # 10:50
Hoje - # 13:15
Ontem - selector: td:nth-child(7):contains("e") - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: ["Hoje", "Today"] - - name: replace - args: ["Ontem", "Yesterday"] - date_year: - # 18:55
03-05-2023 - selector: td:nth-child(7):not(:contains("e")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "HH:mm dd-MM-yyyy" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - files: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(4) - seeders: - selector: a[href$="seeders"] - optional: true - default: 0 - leechers: - selector: a[href$="leechers"] - optional: true - default: 0 - downloadvolumefactor: - case: - img[src="./pic/free.gif"]: 0 - img[src="./pic/silver2.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.4 -# U-232 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/megapeer.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/megapeer.yml deleted file mode 100644 index 61b324ef..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/megapeer.yml +++ /dev/null @@ -1,208 +0,0 @@ ---- -id: megapeer -name: MegaPeer -description: "MegaPeer is a RUSSIAN Public Torrent Tracker for MOVIES / TV" -language: ru-RU -type: public -encoding: windows-1251 -links: - - https://megapeer.vip/ -legacylinks: - - http://megapeer.ru/ # https is not supported - - http://alt.megapeer.ru/ - -caps: - categorymappings: - - {id: 79, cat: Movies, desc: "Наши фильмы (Russian Movies)"} - - {id: 5, cat: TV, desc: "Наши сериалы (Russian Series)"} - - {id: 174, cat: Movies/Foreign, desc: "Зарубежные фильмы (Foreign Movies)"} - - {id: 6, cat: TV/Foreign, desc: "Зарубежные сериалы (Foreign Series)"} - - {id: 76, cat: TV/Anime, desc: "Мультипликация (Animation)"} - - {id: 89, cat: Audio/Video, desc: "Концерты / Клипы (Concerts / Clips)"} - - {id: 57, cat: TV, desc: "Телепередачи / Шоу / Юмор (TV)"} - - {id: 145, cat: TV/Other, desc: "Обучающее видео (Instructional Video)"} - - {id: 55, cat: TV/Documentary, desc: "Документалистика (Documentary)"} - - {id: 164, cat: TV/Other, desc: "Прочее (Other)"} - - {id: 21, cat: TV/Sport, desc: "Спорт (Sport)"} - - {id: 94, cat: Audio, desc: "Музыка (Music)"} - - {id: 28, cat: Console, desc: "Игры (Games)"} - - {id: 107, cat: PC/0day, desc: "Софт / ОС / Оформление (Software)"} - - {id: 41, cat: PC/Mobile-Other, desc: "Мобильные устройства (Mobile)"} - - {id: 52, cat: Books, desc: "Книги / Журналы (Books)"} - - {id: 169, cat: Other/Misc, desc: "Разное (Misc)"} - - {id: 178, cat: Other, desc: "Страницы (Pages)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_category_8000 - type: info_category_8000 - - name: info_flaresolverr - type: info_flaresolverr - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 1: title - 2: size - - name: type - type: select - label: Order requested from site - default: 0 - options: - 0: desc - 1: asc - -search: - # https://megapeer.vip/browse.php?search=%25%25&age=&cat=0&stype=0&sort=0&ascdesc=0 - paths: - - path: browse.php - inputs: - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - # does not support multi category searches - cat: 0 - # 0 title, 1 descr, 2 phrase, 3 infohash - stype: 0 - sort: "{{ .Config.sort }}" - ascdesc: "{{ .Config.type }}" - - headers: - # prevent redirect - Referer: ["{{ .Config.sitelink }}browse.php"] - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: tr.table_fon - - fields: - category: - text: 169 - title: - selector: a[href^="/torrent/"] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(?i)\\bFiles-х\\b", "Files-x"] - - name: re_replace - args: ["(?i)\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="/download/"] - attribute: href - size: - selector: td:nth-last-child(2) - seeders: - selector: td:last-child font:first-of-type - leechers: - selector: td:last-child font:last-of-type - date: - # 28 Мая 24 - selector: td:first-child - filters: - - name: replace - args: ["Янв", "January"] - - name: replace - args: ["Фев", "February"] - - name: replace - args: ["Мар", "March"] - - name: replace - args: ["Апр", "Apr"] - - name: replace - args: ["Мая", "May"] - - name: replace - args: ["Июн", "Jun"] - - name: replace - args: ["Июл", "Jul"] - - name: replace - args: ["Авг", "Aug"] - - name: replace - args: ["Сен", "Sep"] - - name: replace - args: ["Окт", "Oct"] - - name: replace - args: ["Ноя", "Nov"] - - name: replace - args: ["Дек", "Dec"] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "d MMM yy zzz" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mesevilag.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mesevilag.yml deleted file mode 100644 index 589e5e80..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mesevilag.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: mesevilag -name: MeseVilág -description: "MeseVilág (Fairytale World) is a HUNGARIAN Private Torrent Tracker for ANIMATED MOVIES / TV" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://mese-vilag.net/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime/Cam"} - - {id: 2, cat: TV/Anime, desc: "Anime/Eng"} - - {id: 21, cat: TV/Anime, desc: "Anime/HD"} - - {id: 3, cat: TV/Anime, desc: "Anime/Hun"} - - {id: 13, cat: TV/Anime, desc: "Anime/Sorozat(Series)"} - - {id: 4, cat: Movies, desc: "Családi/Cam(Family)"} - - {id: 5, cat: Movies, desc: "Családi/Eng(Family)"} - - {id: 19, cat: Movies/HD, desc: "Családi/HD(Family)"} - - {id: 6, cat: Movies, desc: "Családi/Hun(Family)"} - - {id: 17, cat: Movies, desc: "Családi/Romantikus/Cam(Family Romantic)"} - - {id: 16, cat: Movies, desc: "Családi/Romantikus/Eng(Family Romantic)"} - - {id: 15, cat: Movies, desc: "Családi/Romantikus/Hun(Family Romantic)"} - - {id: 18, cat: TV, desc: "Családi/Romantikus/Sorozat(Family Romantic Series)"} - - {id: 14, cat: TV, desc: "Családi/Sorozat(Family Series)"} - - {id: 8, cat: Audio/Audiobook, desc: "Hangoskönyv(Audiobooks)"} - - {id: 9, cat: PC/Games, desc: "Játék/ISO(Games)"} - - {id: 11, cat: Console, desc: "Játék/Konzol(Console)"} - - {id: 10, cat: PC/Games, desc: "Játék/RIP(Games)"} - - {id: 7, cat: Books, desc: "Könyv(Books)"} - - {id: 20, cat: Movies/HD, desc: "Romantikus/HD(Romantic)"} - - {id: 12, cat: Audio, desc: "Zene/Hun(Music)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: login.php - method: form - form: form#loginForm - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - # error: using some kind of JS popup that can not be trapped :-( - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: letoltes.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 all, 2 dead, 3 freeleech, 4 2x, 5 mesevilag releases, 6 mytorrents, 7 bybookmarks - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 name, 1 descr, 2 both - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not support imdbid search - keywordsfilters: - - name: re_replace # replace special characters with "%" (wildcard) - args: ["[^a-zA-Z0-9]+", "%"] - - rows: - selector: table[cellpadding="5"] > tbody > tr:has(a[onmouseover]) - - fields: - category: - selector: a[href^="letoltes.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="javascript:"] - attribute: title - details: - selector: span - attribute: id - filters: - - name: replace - args: ["span_bookmark_", "details.php?id="] - download: - selector: span - attribute: id - filters: - - name: replace - args: ["span_bookmark_", "download.php?id="] - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) " - date: - selector: td:nth-last-child(6) - filters: - - name: regexp - args: (\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}) - - name: replace - args: ["\xA0", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - seeders: - selector: td:nth-last-child(4) - leechers: - selector: td:nth-last-child(3) - grabs: - selector: td:nth-last-child(2) - description: - case: - img[src="pic/yes.png"]: Verified - img[src="pic/nincs.png"]: Unverified - downloadvolumefactor: - selector: td:nth-last-child(5) > font - case: - "font:contains(\"x0\")": 0 - "*": 1 - uploadvolumefactor: - selector: td:nth-last-child(5) > font - case: - "font:contains(\"x2\")": 2 - "font:contains(\"x3\")": 3 - "font:contains(\"x4\")": 4 - "*": 1 - size: - selector: td:nth-last-child(5) - remove: font - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/metalguru.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/metalguru.yml deleted file mode 100644 index 3c8ff202..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/metalguru.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: metalguru -name: MetalGuru -description: "MetalGuru is a Private Torrent Tracker for GENERAL with focus on MUSIC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://metalguru.me/ - -caps: - categorymappings: - - {id: 51, cat: Audio/Lossless, desc: "24bit"} - - {id: 58, cat: TV/Anime, desc: "Anime"} - - {id: 39, cat: PC/Mobile-Other, desc: "Anything Mobile"} - - {id: 1, cat: PC, desc: "Applications"} - - {id: 2, cat: Books, desc: "Books Ebooks & mags"} - - {id: 59, cat: Movies/SD, desc: "Cams"} - - {id: 52, cat: Audio, desc: "DJ"} - - {id: 53, cat: Audio, desc: "DJ PACKS"} - - {id: 46, cat: Books, desc: "Documents"} - - {id: 47, cat: Audio/Lossless, desc: "Flac/HD Audio"} - - {id: 61, cat: Console, desc: "Games"} - - {id: 62, cat: Audio, desc: "Karaoke"} - - {id: 45, cat: Other, desc: "Misc"} - - {id: 57, cat: Movies, desc: "Movie Packs"} - - {id: 10, cat: Movies, desc: "Movies"} - - {id: 50, cat: Audio, desc: "MQA"} - - {id: 29, cat: Audio, desc: "Music"} - - {id: 31, cat: Audio/Video, desc: "Music| Movie/Videos"} - - {id: 32, cat: Audio, desc: "Music| Packs"} - - {id: 60, cat: TV, desc: "Toons"} - - {id: 54, cat: TV, desc: "TV"} - - {id: 63, cat: TV, desc: "TV Packs"} - - {id: 56, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - use_ssl: 1 - submitme: X - error: - - selector: h2:contains("failed") - message: - selector: td.colhead2 - test: - path: browse.php - selector: a[href*="logout.php?hash_please="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }} {{ else }}{{ end }}{{ .Keywords }}" - # title, descr, genre, all - searchin: "{{ if .Query.IMDBID }}descr{{else}}title{{ end }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 no, 1 yes - only_free: "{{ if .Config.freeleech }}1{{else}}0{{end}}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # no imdbid in results - - rows: - selector: table.table-bordered > tbody > tr:has(a[href^="details.php?id="]) -# filters: -# - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - poster: - selector: a[href^="details.php?id="][onmouseover] - attribute: onmouseover - filters: - # onmouseover="Tip('10:20 AM - # Yesterday
08:03 PM - selector: td:nth-child(7):contains("day") - # auto adjusted by site account profile - optional: true - date_year: - # Feb 14 2019
10:20 AM - selector: td:nth-child(7):not(:contains("day")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM d yyyy hh:mm tt" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - case: - a.infofree: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 48h freeleech (as seconds = 48 x 60 x 60) - # 36h notfree (as seconds = 36 x 60 x 60) - case: - a.infofree: 172800 - "*": 129600 -# U-232 v5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/metaltracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/metaltracker.yml deleted file mode 100644 index 78a95578..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/metaltracker.yml +++ /dev/null @@ -1,110 +0,0 @@ ---- -id: metaltracker -name: Metal Tracker -description: "Metal Tracker is a Semi-Private Torrent Tracker dedicated to HEAVY METAL MUSIC. This definition is for the English site." -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://en.metal-tracker.com/ -legacylinks: - - http://en.metal-tracker.com/ - -caps: - categorymappings: - - {id: Books, cat: Audio/Audiobook, desc: Books} - - {id: Video, cat: Audio/Video, desc: Videos} - - {id: Music, cat: Audio/MP3, desc: Music} - - modes: - search: [q] - music-search: [q, album, artist] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: user/login.html - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - go: Enter - error: - - selector: div.errorSummary - message: - selector: div.errorSummary ul li - test: - path: torrents/search.html - selector: li li:has(a[href="/user/logout.html"]) - -search: - paths: - - path: torrents/search.html - method: post - inputs: - "SearchTorrentsForm[nameTorrent]": "{{ if or (.Query.Artist) (.Query.Album) }}{{ or (.Query.Artist) (.Query.Album) }}{{ else }}{{ .Keywords }}{{ end }}" - "SearchTorrentsForm[sort]": "{{ .Config.sort }}" - "SearchTorrentsForm[sortType]": "{{ .Config.type }}" - go-search: Search - - rows: - selector: .smallalbum - - fields: - title: - selector: a h3 - poster: - selector: .thumb a img - attribute: src - details: - selector: .thumb a - attribute: href - download: - selector: .center a[href^="/torrents/download/id/"] - attribute: href - date: - text: now - seeders: - selector: .center font:nth-of-type(1) - leechers: - selector: .center font:nth-of-type(2) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - category: - selector: .smallalbum - remove: div.thumb, div.center, a - case: - ":contains(\"Type: Music\")": Music - ":contains(\"Type: Video\")": Video - ":contains(\"Type: Books\")": Books - size: - selector: .smallalbum - filters: - - name: regexp - args: "Size:\\s+([\\w\\d\\.,]+ \\w\\w)" -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mikan.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mikan.yml deleted file mode 100644 index 8d6cdbf1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mikan.yml +++ /dev/null @@ -1,75 +0,0 @@ ---- -id: mikan -name: Mikan -description: "Mikan is a CHINESE Public torrent tracker for ANIME" -language: zh-CN -type: public -encoding: UTF-8 -links: - - https://mikanani.me/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: [] - -search: - paths: - # https://mikanani.me/Home/Search?searchstr=%E5%90%8D%E4%BE%A6%E6%8E%A2%E6%9F%AF%E5%8D%97 - - path: "Home/{{ if .Keywords }}Search?searchstr={{ .Keywords }}{{ else }}Classic{{ end }}" - - rows: - selector: table.table-striped tbody tr - - fields: - category: - text: 1 - title: - selector: a[href^="/Home/Episode/"] - details: - selector: a[href^="/Home/Episode/"] - attribute: href - download: - selector: a[href^="/Download/"] - attribute: href - magnet: - selector: a[data-clipboard-text] - attribute: data-clipboard-text - date_day: - selector: td:nth-child(1):contains("今天"), td:nth-child(1):contains("昨天") - optional: true - filters: - - name: replace - args: ["昨天", "Yesterday"] - - name: replace - args: ["今天", "Today"] - - name: fuzzytime - date_year_raw: - selector: td:nth-of-type(4) - optional: true - filters: - - name: regexp - args: "(\\d{4}/\\d{1,2}/\\d{1,2}[\\s\\u00A0]+\\d{1,2}:\\d{2})" - date_year: - text: "{{ if .Result.date_year_raw }}{{ .Result.date_year_raw }} +08:00{{ else }}{{ end }}" - filters: - - name: dateparse - args: "yyyy/MM/dd HH:mm zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - size: - selector: td:has(a[data-clipboard-text]) ~ td - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/milkie.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/milkie.yml deleted file mode 100644 index 04000fa9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/milkie.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: milkie -name: Milkie -description: "Milkie.cc (ME) is Private Torrent Tracker for 0DAY / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://milkie.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: Movies} - - {id: 2, cat: TV, desc: TV} - - {id: 3, cat: Audio, desc: Music} - - {id: 4, cat: PC/Games, desc: Games} - - {id: 5, cat: Books, desc: Ebook} - - {id: 6, cat: PC, desc: Apps} - - {id: 7, cat: XXX, desc: Adult} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new key here." - -search: - headers: - x-milkie-auth: ["{{ .Config.apikey }}"] - - paths: - - path: api/v1/torrents - response: - type: json - - inputs: - ps: 100 - query: "{{ .Keywords }}" - categories: "{{ join .Categories \",\" }}" - - rows: - selector: torrents - count: - selector: hits - - fields: - _id: - selector: id - category: - selector: category - title: - selector: releaseName - details: - text: "/browse/{{ .Result._id }}" - _apikey: - text: "{{ .Config.apikey }}" - filters: - - name: urlencode - download: - text: "/api/v1/torrents/{{ .Result._id }}/torrent?key={{ .Result._apikey }}" - date: - selector: createdAt - size: - selector: size - grabs: - selector: downloaded - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json api v1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/miobt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/miobt.yml deleted file mode 100644 index f18eeba4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/miobt.yml +++ /dev/null @@ -1,95 +0,0 @@ ---- -id: miobt -replaces: - - kisssub -name: MioBT -description: "MioBT is a CHINESE Semi-Private torrent index" -language: zh-CN -type: semi-private -encoding: UTF-8 -links: - - https://miobt.com/ - - https://kisssub.org/ - -caps: - categorymappings: - - {id: 动画, cat: TV/Anime, desc: 动画 (Animation Series and Movies)} - - {id: 漫画, cat: Books/Comics, desc: 漫画 (Comics and Manga)} - - {id: 音乐, cat: Audio, desc: 音乐 (Music OST and Singles)} - - {id: 周边, cat: TV/Other, desc: 周边 (Live Action)} - - {id: 其它, cat: TV/Anime, desc: 其它 (Other)} - - {id: Raw, cat: TV/Anime, desc: Raw} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -login: - path: addon.php?r=document/view&page=visitor-test - method: form - form: form#visitor-test-form - inputs: - visitor_test: human - -search: - paths: - # https://miobt.com/search.php?keyword=moon+720p - - path: "{{ if .Keywords }}search.php?keyword={{ .Keywords }}{{ else }}{{ end }}" - - rows: - selector: table[id="listTable"] > tbody > tr:has( a[href^="show-"]) - - fields: - category: - selector: a[href^="sort-"] - title: - selector: a[href^="show-"] - details: - selector: a[href^="show-"] - attribute: href - infohash: - selector: a[href^="show-"] - attribute: href - filters: - - name: regexp - args: show-([0-9A-Fa-f]{40}) - date_day: - # 今天 00:35 (Today) - # 昨天 22:50 (Yesterday) - optional: true - selector: td:nth-child(1):contains("今天"), td:nth-child(1):contains("昨天") - filters: - - name: replace - args: ["今天", "Today"] - - name: replace - args: ["昨天", "Yesterday"] - - name: append - args: " +08:00" # CST - - name: fuzzytime - date_year: - # 2020/12/24 - optional: true - selector: td:nth-child(1):not(:contains("昨天")):not(:contains("今天")) - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy/MM/dd zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(4) - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine MioBT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mircrew.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mircrew.yml deleted file mode 100644 index c607b4af..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mircrew.yml +++ /dev/null @@ -1,240 +0,0 @@ ---- -id: mircrew -name: MIRCrew -description: "MIRCrew is an ITALIAN Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: semi-private -encoding: UTF-8 -links: - - https://mircrew-releases.org/ - -caps: - # commented out categories are discussion-only forums - categorymappings: - # - {id: 1, cat: Other, desc: "MIRCrew Sharing Community"} - # - {id: 2, cat: Other, desc: "Piazza Grande"} - # - {id: 4, cat: Other, desc: "PG Statuto & Regole"} - # - {id: 5, cat: Other, desc: "PG Info Point"} - # - {id: 6, cat: Other, desc: "PG Presentazioni"} - # - {id: 7, cat: Other, desc: "PG Off Topic"} - # - {id: 49, cat: Other, desc: "PG Segnalazioni allo Staff"} - # - {id: 3, cat: Other, desc: "Comunicazioni, Annunci & Guide"} - # - {id: 8, cat: Other, desc: "CAG Comunicazioni da MIRCrew"} - # - {id: 9, cat: Other, desc: "CAG Guide & Tutorial"} - # - {id: 11, cat: Other, desc: "Progetti & Collezioni"} - # - {id: 12, cat: Other, desc: "P&C Pir8 Channel"} - # - {id: 13, cat: Other, desc: "P&C Progetti"} - # - {id: 61, cat: Other, desc: "P&C Video"} - # - {id: 62, cat: Other, desc: "P&C Animazione"} - # - {id: 63, cat: Other, desc: "P&C Libreria"} - # - {id: 64, cat: Other, desc: "P&C Musica"} - # - {id: 14, cat: Other, desc: "P&C Collezioni"} - # - {id: 50, cat: Other, desc: "P&C Proposte & Richieste"} - # - {id: 60, cat: Other, desc: "P&C Abbandonati"} - # - {id: 15, cat: Other, desc: "Area Grafica"} - # - {id: 16, cat: Other, desc: "AG Vetrina MIRCrew"} - # - {id: 17, cat: Other, desc: "AG Vetrina Utenti"} - # - {id: 18, cat: Other, desc: "AG Richieste Grafica"} - # - {id: 19, cat: Other, desc: "Area Staff"} - # - {id: 20, cat: Other, desc: "AS Membri MIRCrew"} - # - {id: 21, cat: Other, desc: "AS Reclutamento MIRCrew"} - # - {id: 23, cat: Other, desc: "AS Archivio"} - - {id: 25, cat: Movies, desc: "Video Releases", default: true} - - {id: 26, cat: Movies, desc: "Releases Film", default: true} - # - {id: 28, cat: TV, desc: "Releases Serie TV"} - - {id: 51, cat: TV, desc: "Releases TV Stagioni in corso", default: true} - - {id: 52, cat: TV, desc: "Releases TV Stagioni complete", default: true} - - {id: 29, cat: TV/Documentary, desc: "Documentari", default: true} - - {id: 30, cat: TV, desc: "TV Show", default: true} - - {id: 31, cat: TV, desc: "Teatro", default: true} - # - {id: 32, cat: Movies, desc: "Richieste Video"} - # - {id: 53, cat: Movies, desc: "Richieste Film"} - # - {id: 54, cat: TV, desc: "Richieste Serie TV"} - # - {id: 55, cat: Movies, desc: "Richieste Video diverse"} - # - {id: 57, cat: Movies, desc: "Richieste Video Soddisfatte"} - - {id: 33, cat: TV/Anime, desc: "Animazione Releases", default: true} - - {id: 34, cat: Movies/Other, desc: "Anime - Movies", default: true} - - {id: 35, cat: TV/Anime, desc: "Anime - Serie", default: true} - - {id: 36, cat: Movies/Other, desc: "Cartoon - Movies", default: true} - - {id: 37, cat: TV/Anime, desc: "Cartoon - Serie", default: true} - # - {id: 38, cat: TV/Anime, desc: "Richieste Animazione", default: true} - # - {id: 58, cat: TV/Anime, desc: "Richieste Animazione Soddisfatte", default: true} - - {id: 39, cat: Books, desc: "Libreria Releases", default: true} - - {id: 40, cat: Books/EBook, desc: "E-Books", default: true} - - {id: 41, cat: Audio/Audiobook, desc: "A-Books", default: true} - - {id: 42, cat: Books/Comics, desc: "Comics", default: true} - - {id: 43, cat: Books/Mags, desc: "Edicola", default: true} - # - {id: 44, cat: Books, desc: "Richieste Libreria"} - # - {id: 59, cat: Books, desc: "Richieste Libreria Soddisfatte"} - - {id: 45, cat: Audio, desc: "Music Releases", default: true} - - {id: 46, cat: Audio, desc: "Musica - Audio", default: true} - - {id: 47, cat: Audio/Video, desc: "Musica - Video", default: true} - # - {id: 48, cat: Audio, desc: "Richieste Musica"} - # - {id: 56, cat: Audio, desc: "Richieste Musica Soddisfatte"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: radarr_compatibility - type: checkbox - label: Improve Radarr compatibility by removing translated titles from movie results - default: false - -login: - path: ucp.php?mode=login - method: form - form: form#login - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - autologin: on - viewonline: on - error: - - selector: div.error - test: - path: index.php - selector: a[href^="./ucp.php?mode=logout&sid="] - -download: - before: - # thankyou link: ./viewtopic.php?f=52&p=65417&thanks=65417&to_id=54&from_id=3950 - pathselector: - selector: ul.post-buttons li:nth-last-child(1) a - attribute: href - selectors: - - selector: a[href*="magnet:?xt="] - attribute: href - filters: - - name: re_replace - args: ["\n", ""] - -search: - paths: - - path: search.php - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["\\b(?:[SE]\\d{1,4}){1,2}\\b", ""] - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - inputs: - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - # all, any - terms: all - # Search in subforums: yes 1, no 0 - sc: 0 - # Search For: all, msgonly, titleonly, firstpost - sf: titleonly - # Show Results as: posts, topics - sr: topics - # sort: a author, t post time, f forum, i title, s post subject - sk: t - # order: a ascending, d descending - sd: d - # Seach Posts For: all 0, 1day 1, week 7, fortnight 14, month 30, 3months 90, 6 months 180, year 365 - st: 0 - # Show first: all -1, 0, 25, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000 - ch: 300 - # hidden - t: 0 - # hidden - submit: Cerca - $raw: "{{ range .Categories }}&fid[]={{.}}{{end}}" - - headers: - # site blocks Jackett's User-Agents, so use a slightly altered Windows Jackett UA here (e.g. Safari/537.36 > Safari/537.35) - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.35"] - - rows: - selector: li.row - - fields: - category: - selector: a[href^="./viewforum.php?f="] - attribute: href - filters: - - name: querystring - args: f - _is_movie: - selector: a[href^="./viewforum.php?f="] - attribute: href - case: - a[href*="f=25"]: yes - a[href*="f=26"]: yes - a[href*="f=34"]: yes - a[href*="f=36"]: yes - "*": no - title: - selector: a.topictitle - filters: - # normalize Stagione xx to Sxx format - - name: re_replace - args: ["(?i)\\bStagion[ei] (\\d{1,3}(?:-\\d{1,3})?)\\b\\s*(.+?)\\s*(?:\\[IN CORSO\\])?\\s*\\[(\\d{1,3}(?:-\\d{1,3})?)\\/(\\d{1,3})\\]", "S$1E$3 of $4 $2"] - - name: re_replace - args: ["(?i)\\bStagion[ei] (\\d{1,3}(?:-\\d{1,3})?)\\b\\s*(.+?)\\s*(?:\\[IN CORSO\\])?\\s*\\[(\\d{1,3}(?:-\\d{1,3})?)\\]", "S$1E$3 $2"] - - name: re_replace - args: ["(?i)\\bStagion[ei] (\\d{1,3}(?:-\\d{1,3})?)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\s*(?:\\[IN CORSO\\])?\\s*\\[(\\d{1,3}(?:-\\d{1,3})?)\\/(\\d{1,3})\\]", " E$1 of $2"] - - name: re_replace - args: ["(?i)^(.+ )(- .+? )(\\((?:19|20)\\d{2})", "{{ if and (eq .Result._is_movie \"yes\") (.Config.radarr_compatibility) }}$1$3{{ else }}$1$2$3{{ end }}"] - details: - selector: a.topictitle - attribute: href - download: - selector: a.topictitle - attribute: href - size_title: - selector: a.topictitle - filters: - - name: regexp - args: "(?i)[\\[\\({](1?\\d{1,3}(?:[\\.,]\\d{1,2})?\\s*[KMGTP]?i?B)[\\]\\)}]" - _is_4k: - selector: a.topictitle - filters: - - name: regexp - args: "(?i)\\b(2160p|4K|UHD)\\b" - size_default: - selector: a[href^="./viewforum.php?f="] - attribute: href - case: - a[href*="f=25"]: "{{ if .Result._is_4k }}15GB{{ else }}10GB{{ end }}" - a[href*="f=26"]: "{{ if .Result._is_4k }}15GB{{ else }}10GB{{ end }}" - a[href*="f=34"]: "{{ if .Result._is_4k }}15GB{{ else }}10GB{{ end }}" - a[href*="f=36"]: "{{ if .Result._is_4k }}15GB{{ else }}10GB{{ end }}" - a[href*="f=51"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=52"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=29"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=30"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=31"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=33"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=35"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - a[href*="f=37"]: "{{ if .Result._is_4k }}5GB{{ else }}2GB{{ end }}" - "*": 512MB - size: - text: "{{ if .Result.size_title }}{{ .Result.size_title }}{{ else }}{{ .Result.size_default }}{{ end }}" - seeders: - text: 1 - leechers: - text: 1 - date: - selector: time[datetime] - attribute: datetime - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a.topictitle -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mixtapetorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mixtapetorrent.yml deleted file mode 100644 index 169166da..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mixtapetorrent.yml +++ /dev/null @@ -1,70 +0,0 @@ ---- -id: mixtapetorrent -name: MixtapeTorrent -description: "MixtapeTorrent is a Public Music site for MixTapes" -language: en-US -type: public -encoding: UTF-8 -links: - - http://www.mixtapetorrent.com/ # does not support https NET::ERR_CERT_AUTHORITY_INVALID - -caps: - categories: - # while the site has categories for searching, it does not include them in the results. - 1: Audio - - modes: - search: [q] - music-search: [q] - -settings: [] - -download: - selectors: - - selector: table#attachments > tbody > tr.odd > td > a - attribute: href - -search: - paths: - # http://www.mixtapetorrent.com/search/node/2020+type:story - - path: "search/node/{{ if .Keywords }}{{ .Keywords }}{{ else }}2020{{ end }} type:story" - - rows: - selector: dl.search-results > dt, dl.search-results > dd - after: 1 - filters: - - name: andmatch - - fields: - category: - text: 1 - title: - selector: a - details: - selector: a - attribute: href - download: - selector: a - attribute: href - description: - selector: p - date: - selector: p.search-info - filters: - - name: regexp - args: "(\\w{3} \\d{1,2} \\d{4} - \\d{1,2}:\\d{2}[a|p]m)" - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "MMM d yyyy - h:mmtt zzz" - size: - text: "128 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mma-torrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mma-torrents.yml deleted file mode 100644 index 7dc80f1c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mma-torrents.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: mma-torrents -name: MMA-torrents -description: "MMA-Torrents is a Private Torrent Tracker for MMA (Mixed Martial Arts)" -language: en-US -type: private -encoding: UTF-8 -links: - - https://mma-torrents.com/ - -caps: - categorymappings: - - {id: 92, cat: Audio/Other, desc: "Audio - Podcast"} - - {id: 128, cat: TV/Sport, desc: "Boxing - Bare Knuckle"} - - {id: 105, cat: TV/Sport, desc: "Boxing - HD"} - - {id: 71, cat: TV/Sport, desc: "Boxing - SD"} - - {id: 86, cat: TV/Documentary, desc: "Documentary - All"} - - {id: 109, cat: TV/Sport, desc: "Events - Bellator/HD"} - - {id: 89, cat: TV/Sport, desc: "Events - Bellator/SD"} - - {id: 131, cat: TV/Sport, desc: "Events - Cage Warriors"} - - {id: 132, cat: TV/Sport, desc: "Events - Invicta FC"} - - {id: 130, cat: TV/Sport, desc: "Events - KOTC"} - - {id: 90, cat: TV/Sport, desc: "Events - M-1"} - - {id: 120, cat: TV/Sport, desc: "Events - MMA/Brazil"} - - {id: 70, cat: TV/Sport, desc: "Events - MMA/Other/HD"} - - {id: 47, cat: TV/Sport, desc: "Events - MMA/Other/SD"} - - {id: 129, cat: TV/Sport, desc: "Events - ONE"} - - {id: 126, cat: TV/Sport, desc: "Events - Other/Other"} - - {id: 96, cat: TV/Sport, desc: "Events - UFC/HD"} - - {id: 103, cat: TV/Sport, desc: "Events - UFC/SD"} - - {id: 93, cat: Audio/Other, desc: "Instructional - Audio"} - - {id: 112, cat: TV/Sport, desc: "Instructional - DVD/ISO/WEB"} - - {id: 113, cat: Movies/DVD, desc: "Instructional - DVDRip/Other"} - - {id: 73, cat: Books/EBook, desc: "Instructional - E-book"} - - {id: 83, cat: TV/Other, desc: "Instructional - Fitness"} - - {id: 124, cat: TV/Sport, desc: "JJ/BJJ - Events"} - - {id: 85, cat: TV/Sport, desc: "JJ/BJJ - Misc/Other"} - - {id: 99, cat: TV/Sport, desc: "Kickboxing - HD"} - - {id: 84, cat: TV/Sport, desc: "Kickboxing - SD"} - - {id: 111, cat: Books/Mags, desc: "Magazines - All"} - - {id: 58, cat: Other/Misc, desc: "Misc - All"} - - {id: 63, cat: TV/Sport, desc: "Misc - MMA Special"} - - {id: 62, cat: Movies/Other, desc: "Movies - MMA Related"} - - {id: 77, cat: TV/Sport, desc: "Muay Thai - All"} - - {id: 110, cat: TV/Sport, desc: "Packs - Career"} - - {id: 115, cat: TV/Sport, desc: "Packs - Instructionals"} - - {id: 91, cat: TV/Sport, desc: "Packs - Other/HD"} - - {id: 64, cat: TV/Sport, desc: "Packs - Other/SD"} - - {id: 95, cat: TV/Sport, desc: "Packs - Pride"} - - {id: 59, cat: TV/Sport, desc: "Packs - TUF"} - - {id: 114, cat: TV/Sport, desc: "Packs - UFC"} - - {id: 122, cat: TV/Sport, desc: "Packs - Userpacks"} - - {id: 72, cat: TV/Sport, desc: "Retro - K-1"} - - {id: 61, cat: TV/Sport, desc: "Retro - Other/All"} - - {id: 66, cat: TV/Sport, desc: "Retro - PRIDE"} - - {id: 67, cat: TV/Sport, desc: "Retro - UFC"} - - {id: 69, cat: TV/Sport, desc: "Retro - WEC"} - - {id: 97, cat: TV/Sport, desc: "TUF - Episode/HD"} - - {id: 104, cat: TV/Sport, desc: "TUF - Episode/SD"} - - {id: 101, cat: TV/Sport, desc: "TV Show - Episode/HD"} - - {id: 94, cat: TV/Sport, desc: "TV Show - Episode/SD"} - - {id: 127, cat: TV/Sport, desc: "TV Show - WebRip"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: seeders - options: - name: title - size: size - seeders: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: account-login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: "/" - error: - - selector: table.main:contains("Login failed!") - message: - selector: table tr td.text - test: - path: torrents.php - selector: a[href="account-logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - incldead: 1 - lang: 0 - # 0 all 1 notfree 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - - rows: - selector: table[class="ttable_headinner"] tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="torrents.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="torrents-details.php?id="] - attribute: title - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date: - selector: td:nth-child(2) - filters: - - name: regexp - args: "(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})" - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - grabs: - selector: td:nth-child(2) - filters: - - name: regexp - args: "Completed.*(\\d+)" - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src="img/freeleech_star.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# TorrentTrader 2.03 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mnv.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mnv.yml deleted file mode 100644 index 21a9aa48..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mnv.yml +++ /dev/null @@ -1,178 +0,0 @@ ---- -id: mnv -name: MNV -description: "MNV (Max-New-Vision) is a Private GERMAN Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://mnvv2.info/ - -caps: - categorymappings: - - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 2, cat: PC, desc: "Appz"} - - {id: 13, cat: TV/Documentary, desc: "Doku"} - - {id: 3, cat: Books/EBook, desc: "E-Books/Bücher"} - - {id: 24, cat: PC/Games, desc: "Games/PC"} - - {id: 32, cat: TV/Anime, desc: "Hentai"} - - {id: 4, cat: Audio/Audiobook, desc: "Hörspiel/Hörbuch"} - - {id: 6, cat: Movies/HD, desc: "Movies/H.26x"} - - {id: 18, cat: Audio, desc: "Musik"} - - {id: 19, cat: Audio/Video, desc: "Musik Videos"} - - {id: 30, cat: Other, desc: "Sonstiges"} - - {id: 5, cat: Books/Mags, desc: "Tageszeitung"} - - {id: 14, cat: TV, desc: "TV-Serien"} - - {id: 29, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search FreeLeech only - default: false - - name: onlyupload - type: checkbox - label: Search OnlyUpload only - default: false - - name: info_free - type: info - label: About Freeleech and OnlyUpload at MNV - default: "
  • FreeLeech are torrents where neither the download or upload is counted.
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up.
  • Do not set both FreeLeech and OnlyUpload check boxes, there are no torrents with both these flags so you will get a no-results error.
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. The default is 10. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts are automatically deactivated after 42 days." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: .tablea:contains("Fehler") - test: - path: browse.php - selector: a[href="logout.php"] - -search: - # https://mnvv2.info/browse.php?showsearch=1&c13=1&c3=1&search=&blah=0&incldead=1&team=0&orderby=added&sort=desc&language=all - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both, 3 filelist - blah: 0 - # 0 active, 1 all, 2 dead - incldead: 1 - team: 0 - language: all - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - free: "{{ if .Config.onlyupload }}1{{ else }}{{ end }}" - orderby: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - # site does not support imdbid search from the browse.php page or return imdb links in results - - rows: - selector: table.tableinborder > tbody > tr:has(a[href^="download.php"]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php"] - details: - selector: a[href^="details.php"] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - poster: - selector: a[href^="details.php"] - attribute: onMouseover - filters: - - name: regexp - args: src=([^\s]+) - date: - selector: td.tablea > table > tbody > tr:nth-child(2) > td:nth-child(2) > b - filters: - - name: append - args: " +01:00" # CET - - name: replace - args: ["\xA0", " "] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - grabs: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(3) b - size: - selector: a[href^="details.php"] - attribute: onMouseover - filters: - - name: regexp - args: "Größe: (.*?)" - seeders: - selector: a[href^="details.php"] - attribute: onMouseover - filters: - - name: regexp - args: ">(\\d+) Seeder" - leechers: - selector: a[href^="details.php"] - attribute: onMouseover - filters: - - name: regexp - args: ">(\\d+) Leecher" - downloadvolumefactor: - case: - img[src="pic/oupic.gif"]: 0 - img[src="pic/freeleech.gif"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="pic/freeleech.gif"]: 0 - "*": 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mojblink.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mojblink.yml deleted file mode 100644 index 7857906e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mojblink.yml +++ /dev/null @@ -1,234 +0,0 @@ ---- -id: mojblink -name: MOJBLiNK -description: "MOJBLiNK is a SLOVENIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: sl-SI -type: private -encoding: UTF-8 -links: - - https://www.mojblink.si/ - -caps: - categorymappings: - - {id: 50, cat: Movies, desc: "BLiNK"} - - {id: 54, cat: Audio/Audiobook, desc: "eBook AUDIO"} - - {id: 20, cat: Books/EBook, desc: "eBook eBOOK"} - - {id: 52, cat: Books/Other, desc: "eBook Education"} - - {id: 53, cat: Books/Mags, desc: "eBook Magazine"} - - {id: 62, cat: PC/Mobile-Android, desc: "GAMES Android"} - - {id: 12, cat: Console/NDS, desc: "GAMES Nintendo DS"} - - {id: 8, cat: PC/Games, desc: "GAMES PC"} - - {id: 7, cat: PC/Games, desc: "GAMES PCRip"} - - {id: 13, cat: Console/PS3, desc: "GAMES Playstation"} - - {id: 124, cat: Console/PS4, desc: "GAMES PS4"} - - {id: 14, cat: Console/PS4, desc: "GAMES PS5"} - - {id: 16, cat: Console/PSP, desc: "GAMES PSP"} - - {id: 65, cat: Console/Wii, desc: "GAMES Wii"} - - {id: 15, cat: Console/XBox, desc: "GAMES Xbox"} - - {id: 21, cat: Other, desc: "IMAGES Pics"} - - {id: 68, cat: Other, desc: "IMAGES Wallpaper"} - - {id: 70, cat: Movies/SD, desc: "Impatient CAM"} - - {id: 71, cat: Movies/SD, desc: "Impatient DVDSCR"} - - {id: 72, cat: Movies/SD, desc: "Impatient TC"} - - {id: 73, cat: Movies/SD, desc: "Impatient TS"} - - {id: 75, cat: Movies/SD, desc: "Impatient WEBRip"} - - {id: 121, cat: Movies/UHD, desc: "MOVIES 4K"} - - {id: 6, cat: Movies, desc: "MOVIES Animation"} - - {id: 37, cat: TV/Anime, desc: "MOVIES Anime"} - - {id: 40, cat: Movies/BluRay, desc: "MOVIES BD25"} - - {id: 39, cat: Movies/BluRay, desc: "MOVIES BD50"} - - {id: 41, cat: Movies/BluRay, desc: "MOVIES BDRip"} - - {id: 4, cat: Movies/BluRay, desc: "MOVIES BluRay 1080p"} - - {id: 3, cat: Movies/BluRay, desc: "MOVIES BluRay 720p"} - - {id: 43, cat: Movies/BluRay, desc: "MOVIES BRRip x264"} - - {id: 44, cat: Movies/BluRay, desc: "MOVIES BRRip XviD"} - - {id: 25, cat: TV/Documentary, desc: "MOVIES Documentary"} - - {id: 46, cat: Movies/DVD, desc: "MOVIES DVD"} - - {id: 47, cat: Movies/DVD, desc: "MOVIES DVDRip x264"} - - {id: 115, cat: Movies/DVD, desc: "MOVIES DVDRip x265"} - - {id: 1, cat: Movies/DVD, desc: "MOVIES DVDRip XviD"} - - {id: 48, cat: Movies/DVD, desc: "MOVIES HD2DVD"} - - {id: 123, cat: Movies/HD, desc: "MOVIES HDRip"} - - {id: 130, cat: Movies/Other, desc: "MOVIES PACK"} - - {id: 74, cat: Movies/WEB-DL, desc: "MOVIES Web-DL"} - - {id: 120, cat: Movies/HD, desc: "MOVIES x265"} - - {id: 55, cat: Audio/Video, desc: "MUSIC BD"} - - {id: 23, cat: Audio/Video, desc: "MUSIC DVD"} - - {id: 56, cat: Audio/Lossless, desc: "MUSIC FLAC"} - - {id: 11, cat: Audio/MP3, desc: "MUSIC MP3"} - - {id: 57, cat: Audio/Video, desc: "MUSIC MP4"} - - {id: 58, cat: Audio/Video, desc: "MUSIC Video"} - - {id: 26, cat: PC/Mobile-Android, desc: "PROGRAM Android"} - - {id: 27, cat: PC/Mobile-iOS, desc: "PROGRAM iOS"} - - {id: 78, cat: PC, desc: "PROGRAM Linux"} - - {id: 79, cat: PC/Mac, desc: "PROGRAM Mac"} - - {id: 9, cat: PC/ISO, desc: "PROGRAM PC ISO"} - - {id: 80, cat: PC/0day, desc: "PROGRAM PC Win"} - - {id: 5, cat: TV/HD, desc: "TV HDTV"} - - {id: 82, cat: TV/HD, desc: "TV HDTVRip"} - - {id: 126, cat: TV/Other, desc: "TV PACK"} - - {id: 2, cat: TV/SD, desc: "TV TVRip"} - - {id: 125, cat: TV/WEB-DL, desc: "TV Web-DL"} - - {id: 85, cat: Movies/HD, desc: "UpLINK HEVC"} - - {id: 86, cat: Movies/HD, desc: "UpLINK MPEG4"} - - {id: 122, cat: XXX/Other, desc: "XXX Games"} - - {id: 88, cat: XXX/Other, desc: "XXX Magazine"} - - {id: 10, cat: XXX/x264, desc: "XXX Movies"} - - {id: 89, cat: XXX/Pack, desc: "XXX Packs"} - - {id: 90, cat: XXX/ImageSet, desc: "XXX Pics"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 50 on your account Personal Settings.
The Default is 30 - - name: info_xxx - type: info - label: XXX Results - default: To include XXX in results, tick the View XXX Torrents checkbox on your account Personal Settings. - -login: - path: login - method: form - form: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.content-wrapper:has(i.ico-not_warn) - test: - path: / - selector: a[href*="/odjava?hash_insert="] - -search: - paths: - # https://www.mojblink.si/brskanje?cat=0&search=justice+league&searchin=title&incldead=1&only_free=1&redir=no - - path: brskanje - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # title, titledescr, descr - searchin: "{{ if .Query.IMDBID }}descr{{ else }}title{{ end }}" - # 0 active, 1 all, 2 dead - incldead: 1 - only_free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - st: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - redir: no - - headers: - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: div.bgPreview - - fields: - category: - selector: a[href^="/brskanje/"] - attribute: href - filters: - - name: regexp - args: (\d+) - title: - selector: a[href^="/podrobnosti/"] - attribute: title - details: - selector: a[href^="/podrobnosti/"] - attribute: href - download: - selector: a[href^="/download/"] - attribute: href - poster: - selector: div.bgPreview - attribute: style - filters: - - name: regexp - args: "\\((.+?)\\)" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - files: - selector: div.release-info__meta span - date_day: - # Naložen: Danes, 06:46:17 - # Uploaded: Danes, 06:46:17 - selector: div.release-info__meta:contains("Naložen:"):contains("Včeraj"), div.release-info__meta:contains("Naložen:"):contains("Danes"), div.release-info__meta:contains("Uploaded:"):contains("Včeraj"), div.release-info__meta:contains("Uploaded:"):contains("Danes") - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: ["Uploaded: ", ""] - - name: replace - args: ["Naložen: ", ""] - - name: replace - args: ["Včeraj,", "Yesterday"] - - name: replace - args: ["Danes,", "Today"] - - name: fuzzytime - date_year: - # Uploaded: 15.11.2022 07:24:18 - # Naložen: 15.11.2022 07:24:18 - selector: div.release-info__meta:contains("Naložen:"):contains("."), div.release-info__meta:contains("Uploaded:"):contains(".") - # auto adjusted by site account profile - optional: true - filters: - - name: replace - args: ["Uploaded: ", ""] - - name: replace - args: ["Naložen: ", ""] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: a[href^="/download/"] - seeders: - selector: span[title="Sejalci"], span[title="Seeders"] - leechers: - selector: span[title="Odjemalci"], span[title="Leechers"] - downloadvolumefactor: - case: - span.torrent-FL: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 4 days (as seconds = 4 x 24 x 60 x 60) - text: 345600 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/momentpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/momentpt.yml deleted file mode 100644 index d284df16..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/momentpt.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: momentpt -name: MomentPT -description: "MomentPT is a CHINESE Private Torrent Tracker for Photography" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.momentpt.top/ - -caps: - categorymappings: - - {id: 401, cat: Other, desc: "写真(Portraiture)"} - - {id: 402, cat: Other, desc: "人像(Portrait)"} - - {id: 403, cat: Other, desc: "风光(Landscape)"} - - {id: 404, cat: Other, desc: "纪实(Documentary)"} - - {id: 416, cat: Other, desc: "杂志(Magazine)"} - - {id: 415, cat: Other, desc: "静物(Still Life)"} - - {id: 414, cat: Other, desc: "儿童(Children)"} - - {id: 413, cat: Other, desc: "超现实(Surreal)"} - - {id: 412, cat: Other, desc: "美食(Cuisine)"} - - {id: 411, cat: Other, desc: "动物(Animals)"} - - {id: 405, cat: Other, desc: "人文(Humanity)"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.7 2025-09-24 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/monikadesign-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/monikadesign-api.yml deleted file mode 100644 index 57ec18b3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/monikadesign-api.yml +++ /dev/null @@ -1,159 +0,0 @@ ---- -id: monikadesign-api -name: MonikaDesign (API) -description: "MonikaDesign is a CHINESE Private Torrent Tracker for MOVIES / TV / ANIME" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://monikadesign.uk/ - -caps: - categorymappings: - - {id: 8, cat: TV/Anime, desc: "Anime TV"} - - {id: 6, cat: Movies/Other, desc: "Anime Movie"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 9, cat: Audio, desc: "Music of TV"} - - {id: 3, cat: Audio, desc: "Music of Movie"} - - {id: 7, cat: Movies/Other, desc: "Anime Live"} - - {id: 5, cat: Movies, desc: "Action Live"} - - {id: 4, cat: PC, desc: "Game"} - - {id: 11, cat: TV/Anime, desc: "Airing Anime TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your MonikaDesign account My Security page and clicking on the API Token tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "The account will be automatically deleted if you are a registered user who is more than 14 days old and has not uploaded or downloaded data." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title: - selector: name - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)^None$", ""] - - name: replace - args: [" & ", "_&_"] - description: - text: "{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.4 -# json UNIT3D 6.4.1 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mousebits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mousebits.yml deleted file mode 100644 index 5b5f4d94..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mousebits.yml +++ /dev/null @@ -1,134 +0,0 @@ ---- -id: mousebits -name: MouseBits -description: "MouseBits is a Private Torrent Tracker for all things Disney" -language: en-US -type: private -encoding: UTF-8 -links: - - https://mousebits.com/ - -caps: - categorymappings: - # Video - - {id: 34, cat: TV, desc: "Video (DVD ISO)"} - - {id: 14, cat: TV, desc: "Multi-angle tribute"} - - {id: 36, cat: TV, desc: "Martin Vid"} - - {id: 15, cat: TV, desc: "One-take ride-through"} - - {id: 26, cat: TV, desc: "TV Specials"} - - {id: 28, cat: TV, desc: "Promotional Content"} - - {id: 29, cat: TV, desc: "Vacation Planner"} - # Audio - - {id: 30, cat: Audio/MP3, desc: "Music Album (lossy)"} - - {id: 31, cat: Audio/Lossless, desc: "Music Album (FLAC)"} - - {id: 32, cat: Audio, desc: "Attraction Series"} - - {id: 35, cat: Audio, desc: "Background Series"} - - {id: 17, cat: Audio, desc: "Multi-track collection"} - - {id: 18, cat: Audio, desc: "Studio mix ride-through"} - - {id: 19, cat: Audio, desc: "Live recording"} - - {id: 33, cat: Audio, desc: "Induction Recording"} - # Photo - - {id: 21, cat: Other, desc: "Park update"} - - {id: 22, cat: Other, desc: "Desktops"} - - {id: 23, cat: Other, desc: "Vacation photos"} - # Other - - {id: 25, cat: Other, desc: "Community media"} - - {id: 27, cat: Other, desc: "Off-topic"} - - modes: - search: [q] - tv-search: [q, season, ep] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: ?page=login - method: form - form: form - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: span:contains("Password Incorrect") - - selector: span:contains("Username Incorrect") - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://mousebits.com/index.php?page=torrents&active=0&order=3&by=2 - - path: index.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - page: torrents - category: 0 - # 0 all, 1 active, 2 dead - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.lista:last-of-type > tbody > tr:has(a[href^="index.php?page=torrents&category="]) - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details&id="] - details: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - size: - text: "512 MB" - date: - selector: td:nth-child(4) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd/MM/yyyy" - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# xbtit 2.3.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/moviesdvdr.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/moviesdvdr.yml deleted file mode 100644 index d87a62b4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/moviesdvdr.yml +++ /dev/null @@ -1,126 +0,0 @@ ---- -id: moviesdvdr -name: MoviesDVDR -description: "MoviesDVDR is a SPANISH Public tracker for MOVIES" -language: es-ES -type: public -encoding: UTF-8 -links: - - https://www.moviesdvdr.co/ -legacylinks: - - https://www.moviesdvdr.me/ - -caps: - categories: - 1: Movies/DVD - - modes: - search: [q] - movie-search: [q] - -settings: [] - -download: - selectors: - - selector: a.torrent_download - attribute: href - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - - rows: - selector: div.hitem - filters: - - name: andmatch - - fields: - category: - text: 1 - _multi: - optional: true - selector: img.flageitor:nth-child(2) - filters: - - name: re_replace - args: [".*", "MULTi "] - _eng: - optional: true - selector: img.flageitor[src^="/wp-content/themes/moviesdvdr/images/english.png"] - filters: - - name: re_replace - args: [".*", "English "] - _esp: - optional: true - selector: img.flageitor[src^="/wp-content/themes/moviesdvdr/images/espanol.png"] - filters: - - name: re_replace - args: [".*", "SPANiSH "] - _ita: - optional: true - selector: img.flageitor[src^="/wp-content/themes/moviesdvdr/images/italian.png"] - filters: - - name: re_replace - args: [".*", "iTALiAN "] - _deu: - optional: true - selector: img.flageitor[src^="/wp-content/themes/moviesdvdr/images/german.png"] - filters: - - name: re_replace - args: [".*", "German "] - _fra: - optional: true - selector: img.flageitor[src^="/wp-content/themes/moviesdvdr/images/french.png"] - filters: - - name: re_replace - args: [".*", "FRENCH "] - _oth: - optional: true - selector: img.flageitor[src^="/wp-content/themes/moviesdvdr/images/other.png"] - filters: - - name: re_replace - args: [".*", "oTHer "] - _doc: - optional: true - selector: div.imagem span:contains("Documental") - filters: - - name: re_replace - args: ["Documental", "DOC "] - _3x: - optional: true - selector: div.imagem span:contains("Adulto") - filters: - - name: replace - args: ["Adulto", "XXX "] - title: - selector: div.titulo - filters: - - name: append - args: " {{ .Result._multi }}{{ .Result._eng }}{{ .Result._esp }}{{ .Result._ita }}{{ .Result._deu }}{{ .Result._fra }}{{ .Result._oth }}{{ .Result._doc }}{{ .Result._3x }}DVDRiP XViD" - details: - selector: a - attribute: href - download: - selector: a - attribute: href - poster: - selector: img.attachment-post-thumbnail - attribute: src - date: - text: now - description: - selector: span - files: - text: 1 - size: - text: "4.5 GB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/musebootlegs.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/musebootlegs.yml deleted file mode 100644 index 6bc0ff35..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/musebootlegs.yml +++ /dev/null @@ -1,211 +0,0 @@ ---- -id: musebootlegs -name: MuseBootlegs -description: "MuseBootlegs (MB) is a Semi-Private Torrent Tracker for MUSE MUSIC / BOOTLEGS" -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://musebootlegs.com/ -legacylinks: - - https://www.musebootlegs.com/ - -caps: - categorymappings: - - {id: 4, cat: Audio/Lossless, desc: "Audio Lossless"} - - {id: 13, cat: Audio/Lossless, desc: "Audio Lossless 1999"} - - {id: 14, cat: Audio/Lossless, desc: "Audio Lossless 2000"} - - {id: 15, cat: Audio/Lossless, desc: "Audio Lossless 2001"} - - {id: 16, cat: Audio/Lossless, desc: "Audio Lossless 2002"} - - {id: 17, cat: Audio/Lossless, desc: "Audio Lossless 2003"} - - {id: 18, cat: Audio/Lossless, desc: "Audio Lossless 2004"} - - {id: 19, cat: Audio/Lossless, desc: "Audio Lossless 2005"} - - {id: 20, cat: Audio/Lossless, desc: "Audio Lossless 2006"} - - {id: 21, cat: Audio/Lossless, desc: "Audio Lossless 2007"} - - {id: 22, cat: Audio/Lossless, desc: "Audio Lossless 2008"} - - {id: 23, cat: Audio/Lossless, desc: "Audio Lossless 2009"} - - {id: 24, cat: Audio/Lossless, desc: "Audio Lossless 2010"} - - {id: 25, cat: Audio/Lossless, desc: "Audio Lossless 2011"} - - {id: 26, cat: Audio/Lossless, desc: "Audio Lossless 2012"} - - {id: 27, cat: Audio/Lossless, desc: "Audio Lossless 2013"} - - {id: 46, cat: Audio/Lossless, desc: "Audio Lossless 2014"} - - {id: 47, cat: Audio/Lossless, desc: "Audio Lossless 2015"} - - {id: 53, cat: Audio/Lossless, desc: "Audio Lossless 2016"} - - {id: 55, cat: Audio/Lossless, desc: "Audio Lossless 2017"} - - {id: 57, cat: Audio/Lossless, desc: "Audio Lossless 2018"} - - {id: 58, cat: Audio/Lossless, desc: "Audio Lossless 2019"} - - {id: 61, cat: Audio/Lossless, desc: "Audio Lossless 2022"} - - {id: 63, cat: Audio/Lossless, desc: "Audio Lossless 2023"} - - {id: 66, cat: Audio/Lossless, desc: "Audio Lossless 2024"} - - {id: 65, cat: Audio/Lossless, desc: "Audio Lossless 2025"} - - {id: 67, cat: Audio/Lossless, desc: "Audio Lossless 2026"} - - {id: 28, cat: Audio/Lossless, desc: "Audio Lossless Misc"} - - {id: 8, cat: Audio/Video, desc: "DVD Amateur"} - - {id: 3, cat: Audio/Video, desc: "DVD Pro-Shot"} - - {id: 29, cat: Audio/Video, desc: "DVD Pro-Shot 1999"} - - {id: 30, cat: Audio/Video, desc: "DVD Pro-Shot 2000"} - - {id: 31, cat: Audio/Video, desc: "DVD Pro-Shot 2001"} - - {id: 32, cat: Audio/Video, desc: "DVD Pro-Shot 2002"} - - {id: 33, cat: Audio/Video, desc: "DVD Pro-Shot 2003"} - - {id: 34, cat: Audio/Video, desc: "DVD Pro-Shot 2004"} - - {id: 35, cat: Audio/Video, desc: "DVD Pro-Shot 2005"} - - {id: 36, cat: Audio/Video, desc: "DVD Pro-Shot 2006"} - - {id: 37, cat: Audio/Video, desc: "DVD Pro-Shot 2007"} - - {id: 38, cat: Audio/Video, desc: "DVD Pro-Shot 2008"} - - {id: 39, cat: Audio/Video, desc: "DVD Pro-Shot 2009"} - - {id: 40, cat: Audio/Video, desc: "DVD Pro-Shot 2010"} - - {id: 41, cat: Audio/Video, desc: "DVD Pro-Shot 2011"} - - {id: 42, cat: Audio/Video, desc: "DVD Pro-Shot 2012"} - - {id: 43, cat: Audio/Video, desc: "DVD Pro-Shot 2013"} - - {id: 48, cat: Audio/Video, desc: "DVD Pro-Shot 2014"} - - {id: 49, cat: Audio/Video, desc: "DVD Pro-Shot 2015"} - - {id: 54, cat: Audio/Video, desc: "DVD Pro-Shot 2016"} - - {id: 56, cat: Audio/Video, desc: "DVD Pro-Shot 2017"} - - {id: 59, cat: Audio/Video, desc: "DVD Pro-Shot 2018"} - - {id: 60, cat: Audio/Video, desc: "DVD Pro-Shot 2019"} - - {id: 62, cat: Audio/Video, desc: "DVD Pro-Shot 2022"} - - {id: 68, cat: Audio/Video, desc: "DVD Pro-Shot 2023"} - - {id: 69, cat: Audio/Video, desc: "DVD Pro-Shot 2024"} - - {id: 70, cat: Audio/Video, desc: "DVD Pro-Shot 2025"} - - {id: 71, cat: Audio/Video, desc: "DVD Pro-Shot 2026"} - - {id: 44, cat: Audio/Video, desc: "DVD Pro-Shot Compilations"} - - {id: 45, cat: Audio/Video, desc: "DVD Pro-Shot Music Videos"} - - {id: 7, cat: Audio/Video, desc: "High-Definition"} - - {id: 51, cat: Audio/Video, desc: "High-Definition Pro-Shot"} - - {id: 50, cat: Audio/Video, desc: "High-Definition AMT"} - - {id: 52, cat: Audio/Video, desc: "High-Definition Music Videos"} - - {id: 6, cat: Audio/Video, desc: "Lossy Audio/Video"} - - {id: 9, cat: Audio/Video, desc: "MBDVD"} - - {id: 5, cat: Other, desc: "Misc"} - - {id: 12, cat: Other, desc: "3D"} - - modes: - search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and check that you are not being forced to change your password because it has expired after 180 days." - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: ?p=home&pid=1 - method: form - form: form#loginbox_form - submitpath: ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: div.error - - selector: :contains("-ERROR-") - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - - path: / - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}&cid[]={{.}}{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - - rows: - selector: div.torrent-box[id^="torrent_"] - - fields: - category: - selector: a[href*="?p=torrents&pid=10&cid="] - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - details: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download&tid="] - attribute: href - poster: - selector: a[href*="data/torrents/torrent_images/"] - attribute: href - size: - selector: a[rel="torrent_size"] - grabs: - selector: a[rel="times_completed"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - date_day: - selector: div.torrentOwner:contains("day") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - selector: div.torrentOwner:contains("-") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - downloadvolumefactor: - optional: true - default: 1 - selector: img[src$="/torrent_free.png"] - attribute: title - filters: - - name: regexp - args: (\d+(\.\d+)?) - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# TSUE 2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/muxuege.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/muxuege.yml deleted file mode 100644 index ed05b9d2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/muxuege.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -id: muxuege -name: Muxuege -description: "Muxuege is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.muxuege.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影/Movies"} - - {id: 402, cat: TV, desc: "电视剧/TVSeries"} - - {id: 403, cat: TV, desc: "综艺/TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentary"} - - {id: 405, cat: TV/Anime, desc: "动漫/Anime"} - - {id: 406, cat: Audio/Video, desc: "MV/Music Videos"} - - {id: 407, cat: TV/Sport, desc: "体育/Sports"} - - {id: 408, cat: Audio, desc: "音乐/Music"} - - {id: 418, cat: Audio, desc: "广播剧/Radio Dramas"} - - {id: 417, cat: Audio/Audiobook, desc: "有声书/Audiobooks"} - - {id: 416, cat: TV, desc: "短剧/Playlet"} - - {id: 415, cat: PC, desc: "软件/Software"} - - {id: 414, cat: Other, desc: "图片/Pictures"} - - {id: 413, cat: Other, desc: "教育/Education"} - - {id: 412, cat: Books, desc: "电子书/E-books"} - - {id: 411, cat: Console, desc: "游戏/Games"} - - {id: 410, cat: PC/ISO, desc: "系统镜像/System Images"} - - {id: 409, cat: Other, desc: "其他/Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mvgroupforum.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mvgroupforum.yml deleted file mode 100644 index d923e5a8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mvgroupforum.yml +++ /dev/null @@ -1,227 +0,0 @@ ---- -id: mvgroupforum -name: MVGroup Forum -description: "MVGroup is a Semi-Private Torrent Tracker dedicated to UK TV and DOCUMENTARIES. This definition is for the Forum Tracker site." -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://forums.mvgroup.org/ -legacylinks: - - http://forums.mvgroup.org/ - -caps: - categories: - Other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_flaresolverr - type: info_flaresolverr - - name: info_category_8000 - type: info_category_8000 - - name: hidef - type: checkbox - label: Show MVGroup HiDef Releases only - default: false - - name: withsubs - type: select - label: Subtitles - default: _ - options: - _: -OFF- - alb: Albanian - ara: Arabic - arm: Armenian - ass: Assyrian - bos: Bosnian - bul: Bulgarian - cat: Catalan - chi: "Chinese, simplified" - zho: "Chinese, traditional" - hrv: Croatian - cze: Czech - dan: Danish - dut: Dutch - eng: English - est: Estonian - fin: Finnish - fre: French - glg: Galician - geo: Georgian - ger: German - ell: Greek - heb: Hebrew - hin: Hindi - hun: Hungarian - ice: Icelandic - ind: Indonesian - ita: Italian - jpn: Japanese - kaz: Kazakh - kor: Korean - lav: Latvian - lit: Lithuanian - ltz: Luxembourgish - mac: Macedonian - may: Malay - nor: Norwegian - per: Persian - pol: Polish - por: Portuguese - rum: Romanian - rus: Russian - scc: Serbian - slo: Slovak - slv: Slovenian - spa: Spanish - swe: Swedish - tha: Thai - tur: Turkish - ukr: Ukrainian - vie: Vietnamese - - name: seed - type: checkbox - label: Only released and seeded torrents - default: false - - name: dropbbc - type: checkbox - label: Drop BBC & other channels from the front of result titles - default: false - - name: stripS01E01 - type: checkbox - label: Strip the S01E01 from the Torznab search requests - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - filename: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: index.php?act=Login&CODE=00 - method: form - form: form[action$="act=Login&CODE=01"] - inputs: - UserName: "{{ .Config.username }}" - PassWord: "{{ .Config.password }}" - Privacy: 1 - CookieDate: 1 - CODE: 01 - referer: "https://forums.mvgroup.org/index.php?" - act: Login - error: - - selector: div.borderwrap:has(div.tablepad) - message: - selector: div.borderwrap div.tablepad - test: - path: index.php - selector: tr td.nav_mem div span[id="member_menu"], a[href^="/maintracker.php?filter="] - -search: - paths: - - path: forumtracker.php - inputs: - seed: "{{ if .Config.seed }}1{{ else }}0{{ end }}" - withsubs: "{{ re_replace .Config.withsubs \"_\" \"\" }}" - filter: "{{ if .Config.hidef }}hd+{{ else }}{{ end }}{{ .Keywords }}" - orderby: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - keywordsfilters: - - name: re_replace - args: ["(?i)S01E01", "{{ if .Config.stripS01E01 }}{{ else }}S01E01{{ end }}"] # optionally remove S01E01 from search - - rows: - selector: tr:has(a.magnetlink) - - fields: - category: - text: Other - title: - selector: td.doubleindent, td.singleindent a[href^="/index.php?showtopic="] - optional: true - # fallback title (to prevent abend if optional is not found) - default: "title not found" - filters: - - name: re_replace - args: ["(?i)^BBC (.*)", "{{ if .Config.dropbbc }}{{ else }}BBC {{ end }}$1"] - - name: re_replace - args: ["(?i)^PBS (.*)", "{{ if .Config.dropbbc }}{{ else }}PBS {{ end }}$1"] - - name: re_replace - args: ["(?i)^CH5 (.*)", "{{ if .Config.dropbbc }}{{ else }}Ch5 {{ end }}$1"] - - name: re_replace - args: ["(?i)^CH4 (.*)", "{{ if .Config.dropbbc }}{{ else }}Ch4 {{ end }}$1"] - - name: re_replace - args: ["(?i)^DC (.*)", "{{ if .Config.dropbbc }}{{ else }}DC {{ end }}$1"] - - name: re_replace - args: ["(?i)^DW (.*)", "{{ if .Config.dropbbc }}{{ else }}DW {{ end }}$1"] - - name: re_replace - args: ["(?i)^HC (.*)", "{{ if .Config.dropbbc }}{{ else }}HC {{ end }}$1"] - - name: re_replace - args: ["(?i)^RTE (.*)", "{{ if .Config.dropbbc }}{{ else }}RTE {{ end }}$1"] - - name: re_replace - args: ["(?i)^ITV (.*)", "{{ if .Config.dropbbc }}{{ else }}ITV {{ end }}$1"] - - name: re_replace - args: ["(?i)^HBO (.*)", "{{ if .Config.dropbbc }}{{ else }}HBO {{ end }}$1"] - - name: re_replace - args: ["(?i)^NHK (.*)", "{{ if .Config.dropbbc }}{{ else }}NHK {{ end }}$1"] - - name: re_replace - args: ["(?i)^UKTV (.*)", "{{ if .Config.dropbbc }}{{ else }}UKTV {{ end }}$1"] - - name: re_replace - args: ["(?i)^NOVA (.*)", "{{ if .Config.dropbbc }}{{ else }}NOVA {{ end }}$1"] - - name: re_replace - args: ["(?i)^TV5Monde (.*)", "{{ if .Config.dropbbc }}{{ else }}TV5Monde {{ end }}$1"] - - name: re_replace - args: ["(?i)^Al-Jazeera (.*)", "{{ if .Config.dropbbc }}{{ else }}Al-Jazeera {{ end }}$1"] - - name: append - args: "{{ if .Config.stripS01E01 }}{{ else }} S01E01{{ end }}" - details: - optional: true - selector: td.singleindent a[href^="/index.php?showtopic="] - attribute: href - download: - selector: td a.torrentlink - attribute: href - magnet: - selector: td a.magnetlink - attribute: href - size: - text: "512 MB" - date: - selector: td:nth-of-type(3) - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy/MM/dd zzz" - seeders: - selector: td:nth-of-type(4) - leechers: - selector: td:nth-of-type(5) - grabs: - selector: td:nth-of-type(6) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Invision Power Board diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mvgroupmain.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mvgroupmain.yml deleted file mode 100644 index 53a8b4c8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mvgroupmain.yml +++ /dev/null @@ -1,227 +0,0 @@ ---- -id: mvgroupmain -name: MVGroup Main -description: "MVGroup is a Semi-Private Torrent Tracker dedicated to UK TV and DOCUMENTARIES. This definition is for the Main Tracker site." -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://forums.mvgroup.org/ -legacylinks: - - http://forums.mvgroup.org/ - -caps: - categories: - Other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_flaresolverr - type: info_flaresolverr - - name: info_category_8000 - type: info_category_8000 - - name: hidef - type: checkbox - label: Show MVGroup HiDef Releases only - default: false - - name: withsubs - type: select - label: Subtitles - default: _ - options: - _: -OFF- - alb: Albanian - ara: Arabic - arm: Armenian - ass: Assyrian - bos: Bosnian - bul: Bulgarian - cat: Catalan - chi: "Chinese, simplified" - zho: "Chinese, traditional" - hrv: Croatian - cze: Czech - dan: Danish - dut: Dutch - eng: English - est: Estonian - fin: Finnish - fre: French - glg: Galician - geo: Georgian - ger: German - ell: Greek - heb: Hebrew - hin: Hindi - hun: Hungarian - ice: Icelandic - ind: Indonesian - ita: Italian - jpn: Japanese - kaz: Kazakh - kor: Korean - lav: Latvian - lit: Lithuanian - ltz: Luxembourgish - mac: Macedonian - may: Malay - nor: Norwegian - per: Persian - pol: Polish - por: Portuguese - rum: Romanian - rus: Russian - scc: Serbian - slo: Slovak - slv: Slovenian - spa: Spanish - swe: Swedish - tha: Thai - tur: Turkish - ukr: Ukrainian - vie: Vietnamese - - name: seed - type: checkbox - label: Only released and seeded torrents - default: false - - name: dropbbc - type: checkbox - label: Drop BBC & other channels from the front of result titles - default: false - - name: stripS01E01 - type: checkbox - label: Strip the S01E01 from the Torznab search requests - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - filename: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: index.php?act=Login&CODE=00 - method: form - form: form[action$="act=Login&CODE=01"] - inputs: - UserName: "{{ .Config.username }}" - PassWord: "{{ .Config.password }}" - Privacy: 1 - CookieDate: 1 - CODE: 01 - referer: "https://forums.mvgroup.org/index.php?" - act: Login - error: - - selector: div.borderwrap:has(div.tablepad) - message: - selector: div.borderwrap div.tablepad - test: - path: index.php - selector: tr td.nav_mem div span[id="member_menu"], a[href^="/forumtracker.php?filter="] - -search: - paths: - - path: maintracker.php - inputs: - seed: "{{ if .Config.seed }}1{{ else }}0{{ end }}" - withsubs: "{{ re_replace .Config.withsubs \"_\" \"\" }}" - filter: "{{ if .Config.hidef }}hd+{{ else }}{{ end }}{{ .Keywords }}" - orderby: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - keywordsfilters: - - name: re_replace - args: ["(?i)S01E01", "{{ if .Config.stripS01E01 }}{{ else }}S01E01{{ end }}"] # optionally remove S01E01 from search - - rows: - selector: tr:has(a.magnetlink) - - fields: - category: - text: Other - title: - selector: td.doubleindent, td.singleindent a[href^="/index.php?showtopic="] - optional: true - # fallback title (to prevent abend if optional is not found) - default: "title not found" - filters: - - name: re_replace - args: ["(?i)^BBC (.*)", "{{ if .Config.dropbbc }}{{ else }}BBC {{ end }}$1"] - - name: re_replace - args: ["(?i)^PBS (.*)", "{{ if .Config.dropbbc }}{{ else }}PBS {{ end }}$1"] - - name: re_replace - args: ["(?i)^CH5 (.*)", "{{ if .Config.dropbbc }}{{ else }}Ch5 {{ end }}$1"] - - name: re_replace - args: ["(?i)^CH4 (.*)", "{{ if .Config.dropbbc }}{{ else }}Ch4 {{ end }}$1"] - - name: re_replace - args: ["(?i)^DC (.*)", "{{ if .Config.dropbbc }}{{ else }}DC {{ end }}$1"] - - name: re_replace - args: ["(?i)^DW (.*)", "{{ if .Config.dropbbc }}{{ else }}DW {{ end }}$1"] - - name: re_replace - args: ["(?i)^HC (.*)", "{{ if .Config.dropbbc }}{{ else }}HC {{ end }}$1"] - - name: re_replace - args: ["(?i)^RTE (.*)", "{{ if .Config.dropbbc }}{{ else }}RTE {{ end }}$1"] - - name: re_replace - args: ["(?i)^ITV (.*)", "{{ if .Config.dropbbc }}{{ else }}ITV {{ end }}$1"] - - name: re_replace - args: ["(?i)^HBO (.*)", "{{ if .Config.dropbbc }}{{ else }}HBO {{ end }}$1"] - - name: re_replace - args: ["(?i)^NHK (.*)", "{{ if .Config.dropbbc }}{{ else }}NHK {{ end }}$1"] - - name: re_replace - args: ["(?i)^UKTV (.*)", "{{ if .Config.dropbbc }}{{ else }}UKTV {{ end }}$1"] - - name: re_replace - args: ["(?i)^NOVA (.*)", "{{ if .Config.dropbbc }}{{ else }}NOVA {{ end }}$1"] - - name: re_replace - args: ["(?i)^TV5Monde (.*)", "{{ if .Config.dropbbc }}{{ else }}TV5Monde {{ end }}$1"] - - name: re_replace - args: ["(?i)^Al-Jazeera (.*)", "{{ if .Config.dropbbc }}{{ else }}Al-Jazeera {{ end }}$1"] - - name: append - args: "{{ if .Config.stripS01E01 }}{{ else }} S01E01{{ end }}" - details: - optional: true - selector: td.singleindent a[href^="/index.php?showtopic="] - attribute: href - download: - selector: td a.torrentlink - attribute: href - magnet: - selector: td a.magnetlink - attribute: href - size: - text: "512 MB" - date: - selector: td:nth-of-type(3) - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy/MM/dd zzz" - seeders: - selector: td:nth-of-type(4) - leechers: - selector: td:nth-of-type(5) - grabs: - selector: td:nth-of-type(6) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Invision Power Board diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mypornclub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mypornclub.yml deleted file mode 100644 index 92c74f34..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/mypornclub.yml +++ /dev/null @@ -1,70 +0,0 @@ ---- -id: mypornclub -name: MyPornClub -description: "MyPornClub is a Public Torrent Tracker for 3X" -language: en-US -type: public -encoding: UTF-8 -links: - - https://myporn.club/ - -caps: - categorymappings: - - {id: XXX, cat: XXX, desc: "XXX"} - - modes: - search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - filters: - - name: re_replace - args: ["\\s+", " "] - -search: - paths: - - path: "{{ if .Keywords }}s/{{ re_replace .Keywords \"\\s+\" \"-\" }}{{ else }}ts{{ end }}" - - rows: - selector: div.torrents_list > div.torrent_element - filters: - - name: andmatch - - fields: - category: - text: XXX - title: - selector: a[href^="/t/"] - remove: i - details: - selector: a[href^="/t/"] - attribute: href - download: - selector: a[href^="/t/"] - attribute: href - date: - selector: div.torrent_element_info span:nth-child(2) - filters: - - name: replace - args: ["Last year", "1 year ago"] - - name: replace - args: ["Last month", "1 month ago"] - size: - selector: div.torrent_element_info span:nth-child(4) - grabs: - selector: div.torrent_element_info span:nth-child(8) - seeders: - selector: div.torrent_element_info span:nth-child(10) - leechers: - selector: div.torrent_element_info span:nth-child(12) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/myspleen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/myspleen.yml deleted file mode 100644 index bffdf49b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/myspleen.yml +++ /dev/null @@ -1,101 +0,0 @@ ---- -id: myspleen -name: MySpleen -description: "MySpleen is a Private Torrent Tracker for TV / COMEDY / ANIMATION / 80-90’S VHS NOSTALGIA" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.myspleen.org/ - -caps: - categorymappings: - - {id: 31, cat: TV, desc: "Adult Swim"} - - {id: 30, cat: TV, desc: "Animation"} - - {id: 25, cat: TV, desc: "Cartoon Network"} - - {id: 3, cat: TV, desc: "Comedy"} - - {id: 26, cat: TV, desc: "Comedy Central"} - - {id: 24, cat: TV, desc: "MST3K"} - - {id: 28, cat: TV, desc: "MTV"} - - {id: 29, cat: TV, desc: "Nick"} - - {id: 20, cat: Other, desc: "Other"} - - {id: 32, cat: TV, desc: "Star Wars"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: browse.php - selector: span.key:contains("Ratio") + span.value - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - incldead: 1 - title: 0 - - rows: - selector: table#main-torrents > tbody > tr - - fields: - category: - selector: td[class^="cat-"] > a - attribute: href - filters: - - name: querystring - args: cat - title: - selector: td.tor-name > a - attribute: title - details: - selector: td.tor-name > a - attribute: href - download: - selector: td.tor-down > a - attribute: href - files: - selector: td:nth-child(5) - date: - selector: td:nth-child(7) - size: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - filters: - - name: regexp - args: "(\\d+)" - seeders: - selector: td:nth-child(10) - filters: - - name: regexp - args: "^(\\d+)" - leechers: - selector: td:nth-child(11) - filters: - - name: regexp - args: "^(\\d+)" - downloadvolumefactor: - case: - span.star: 0 - span.fltime: 0 - ":root li[id=\"alert-fl\"][class=\"alert\"]:contains(\"Freeleech ends in \")": 0 - "*": 1 - uploadvolumefactor: - text: 1 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nanyangpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nanyangpt.yml deleted file mode 100644 index 6cfc5802..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nanyangpt.yml +++ /dev/null @@ -1,174 +0,0 @@ ---- -id: nanyangpt -name: NanyangPT (南洋) -description: "NanyangPT (南洋) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://nanyangpt.com/ -legacylinks: - - https://52pt.site/ # loaded in error when first set up from template - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 403, cat: TV/Anime, desc: "Anime/动漫"} - - {id: 404, cat: TV, desc: "TV Shows/综艺"} - - {id: 405, cat: TV/Sport, desc: "Sports/运体"} - - {id: 406, cat: TV/Documentary, desc: "Documentaries/纪录"} - - {id: 407, cat: Audio, desc: "Music/音乐"} - - {id: 408, cat: Other, desc: "Study/学习"} - - {id: 409, cat: PC, desc: "Software/软件"} - - {id: 410, cat: Console, desc: "Games/游戏"} - - {id: 411, cat: Other, desc: "Misc/其它"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. University lecturer and above users will be retained forever
  2. Excellent master's degree and above users will not be deleted after the account is parked (in the control panel)
  3. Users with a parked account who do not log in for 400 consecutive days will be deleted
  4. Users with non-parked account will be deleted if they do not log in for 150 consecutive days
" - -login: - # using cookie method as login.php is different depending on IP address - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href$="usercp.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead, 3 noseeds - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # does not return imdb or doubanid in results - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NYPT Project v1.0 2014-10-01 (based on NexusPHP) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ncore.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ncore.yml deleted file mode 100644 index 9fb81d28..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ncore.yml +++ /dev/null @@ -1,190 +0,0 @@ ---- -id: ncore -name: nCore -description: "nCore is a HUNGARIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: UTF-8 -links: - - https://ncore.pro/ -legacylinks: - - https://ncore.cc/ - -caps: - categorymappings: - # E-book - - {id: ebook, cat: Books, desc: "E-book/Eng"} - - {id: ebook_hun, cat: Books, desc: "E-book/Hun"} - # Játék - - {id: console, cat: Console, desc: "Játék/Console"} - - {id: game_iso, cat: PC/Games, desc: "Játék/ISO"} - - {id: game_rip, cat: PC/Games, desc: "Játék/Rip"} - # Filmek - - {id: xvid_hun, cat: Movies/SD, desc: "Film/SD/Hun"} - - {id: xvid, cat: Movies/SD, desc: "Film/SD/Eng"} - - {id: dvd_hun, cat: Movies/DVD, desc: "Film/DVD/Hun"} - - {id: dvd, cat: Movies/DVD, desc: "Film/DVD/Eng"} - - {id: dvd9_hun, cat: Movies/DVD, desc: "Film/Dvd9/Hun"} - - {id: dvd9, cat: Movies/DVD, desc: "Film/Dvd9/Eng"} - - {id: hd_hun, cat: Movies/HD, desc: "Film/HD/Hun"} - - {id: hd, cat: Movies/HD, desc: "Film/HD/Eng"} - # Sorozatok - - {id: xvidser, cat: TV/SD, desc: "Sorozat/SD/Eng"} - - {id: xvidser_hun, cat: TV/SD, desc: "Sorozat/SD/Hun"} - - {id: dvdser, cat: TV/Other, desc: "Sorozat/DVDR/Eng"} - - {id: dvdser_hun, cat: TV/Other, desc: "Sorozat/DVDR/Hun"} - - {id: hdser, cat: TV/HD, desc: "Sorozat/HD/Eng"} - - {id: hdser_hun, cat: TV/HD, desc: "Sorozat/HD/Hun"} - # Zene - - {id: mp3_hun, cat: Audio/MP3, desc: "Zene/MP3/Hun"} - - {id: mp3, cat: Audio/MP3, desc: "Zene/MP3/Eng"} - - {id: lossless_hun, cat: Audio/MP3, desc: "Zene/Lossless/Hun"} - - {id: lossless, cat: Audio/MP3, desc: "Zene/Lossless/Eng"} - - {id: clip, cat: Audio/Video, desc: "Zene/Klip"} - # Program - - {id: iso, cat: PC/ISO, desc: "Win/Program/ISO"} - - {id: misc, cat: PC/0day, desc: "Win/Program/Rip"} - - {id: mobil, cat: PC/Mobile-Other, desc: "Win/Program/Rip"} - # Felnőtt tartalom - - {id: xxx_xvid, cat: XXX/SD, desc: "XXX/Film/SD"} - - {id: xxx_dvd, cat: XXX/DVD, desc: "XXX/DVDR"} - - {id: xxx_hd, cat: XXX/UHD, desc: "XXX/HD"} - - {id: xxx_imageset, cat: XXX/ImageSet, desc: "XXX/Kép"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, genre] - movie-search: [q, imdbid, genre] - music-search: [q, genre] - book-search: [q, genre] - allowrawsearch: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the nCore Web Site. Otherwise just leave it empty." - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrent oldalanként: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Anyone who does not access the site for 8 weeks from registration or has been more than 8 weeks since their last stay will be automatically suspended. Anyone who does not log in (stays inactive) for 12 weeks after registration or after their last visit will be removed automatically." - -login: - method: post - path: login.php - inputs: - nev: "{{ .Config.username }}" - pass: "{{ .Config.password }}" - 2factor: "{{ .Config.2facode }}" - ne_leptessen_ki: "1" - set_lang: "en" - submitted: "1" - error: - - selector: div#hibauzenet table tbody tr - test: - path: index.php - selector: a[href*="profile.php"] - -download: - selectors: - - selector: a[href^="torrents.php?action=download&id="] - attribute: href - -search: - paths: - - path: torrents.php - inputs: - mire: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - miben: "{{ if .Query.IMDBID }}imdb{{ else }}name{{ end }}" - tipus: "{{ if .Categories }}kivalasztottak_kozott{{ else }}all_own{{ end }}" - submit.x: 1 - submit.y: 1 - tags: "{{ .Query.Genre }}" - $raw: "{{ range .Categories }}&kivalasztott_tipus[]={{.}}{{end}}" - - rows: - selector: .box_torrent_all .box_torrent - - fields: - category: - selector: .box_alap_img > a - attribute: href - filters: - - name: querystring - args: tipus - title: - selector: div[class^="torrent_txt"] > a - attribute: title - details: - selector: div[class^="torrent_txt"] > a - attribute: href - download: - selector: div[class^="torrent_txt"] > a - attribute: href - filters: - - name: replace - args: ["torrents.php?action=details", "ajax.php?action=torrent_drop"] -# image is returned with invalid Content-Type header 'image' -# poster: -# selector: div.infobar > img -# attribute: onmouseover -# filters: -# - name: regexp -# args: "'(.+?)'" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - filters: - - name: replace - args: ["https://dereferer.link/?", ""] - size: - selector: div.box_meret2 - seeders: - selector: div.box_s2 - leechers: - selector: div.box_l2 - date: - selector: div.box_feltoltve2 - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# MST is 48 hours + 24 minutes (.4 hours) per GB of downloaded size -# minimumseedtime: -# # 2 days (as seconds = 2 x 24 x 60 x 60) -# text: 172800 - description_imdb: - selector: a.infolink - optional: true - description_alt: - selector: div.siterank > span - attribute: title - optional: true - description: - case: - div.torrent_ok: "Verified" - div.torrent_unchecked: "Unverified" - filters: - - name: append - args: "{{ if or .Result.description_alt .Result.description_imdb }}: {{ else }}{{ end }}{{ .Result.description_alt }}{{ if and .Result.description_alt .Result.description_imdb }} {{ else }}{{ end }}{{ .Result.description_imdb }}" -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nekobt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nekobt.yml deleted file mode 100644 index f9c48c14..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nekobt.yml +++ /dev/null @@ -1,147 +0,0 @@ ---- -id: nekobt -name: nekoBT -description: "nekoBT is a Public Torrent Tracker for ANIME" -language: en-US -type: public -encoding: UTF-8 -links: - - https://nekobt.to/ - -caps: - categorymappings: - # from https://nekobt.to/api/torznab/api?t=caps - - {id: 5070, cat: TV/Anime, desc: "Anime"} - - {id: 2000, cat: Movies/Other, desc: "Movies"} - - modes: - search: [q] - tv-search: [q, season, ep, tvdbid] - movie-search: [q] - -settings: - - name: apikey - type: text - label: API Key (Optional) - - name: info_key - type: info - label: About your API key - default: "API key is optional. You can find or generate a new API Key by accessing your nekoBT account settings page if needed." - - name: levels - type: select - label: Subtitle Level - default: _ - options: - _: "All" - "0": "Level 0" - "1": "Level 1" - "2": "Level 2" - "3": "Level 3" - "4": "Level 4" - - name: audio_lang - type: text - label: Audio Language (comma-separated codes, e.g. en,ja) - - name: fansub_lang - type: text - label: Fansub Language (comma-separated codes, e.g. en,ja) - - name: sub_lang - type: text - label: Subtitle Language (comma-separated codes, e.g. en,ja) - - name: hardsub - type: select - label: Hardcoded Subtitles - default: _ - options: - _: "No Filter" - "true": "Include Only" - "false": "Exclude" - - name: otl - type: select - label: OTL Subtitles - default: _ - options: - _: "No Filter" - "true": "Include Only" - "false": "Exclude" - - name: mtl - type: select - label: MTL Subtitles - default: _ - options: - _: "No Filter" - "true": "Include Only" - "false": "Exclude" - - name: group_id - type: text - label: Group ID - default: "" - -search: - paths: - - path: api/torznab/api - response: - type: xml - - inputs: - t: "search" - q: "{{ .Keywords }}" - apikey: "{{ .Config.apikey }}" - tvdbid: "{{ .Query.TVDBID }}" - season: "{{ .Query.Season }}" - ep: "{{ .Query.Ep }}" - levels: "{{ re_replace .Config.levels \"_\" \"\" }}" - audio_lang: "{{ .Config.audio_lang }}" - fansub_lang: "{{ .Config.fansub_lang }}" - sub_lang: "{{ .Config.sub_lang }}" - hardsub: "{{ re_replace .Config.hardsub \"_\" \"\" }}" - otl: "{{ re_replace .Config.otl \"_\" \"\" }}" - mtl: "{{ re_replace .Config.mtl \"_\" \"\" }}" - group_id: "{{ .Config.group_id }}" - limit: 100 - - rows: - selector: rss > channel > item - - fields: - title: - selector: title - filters: - - name: re_replace - args: ["(.+?) {Tags:.+", "$1"] - description: - selector: title - filters: - - name: re_replace - args: [".+ {(Tags:.+?)}", "$1"] - details: - selector: comments - magnet: - selector: enclosure - attribute: url - date: - # Sat, 03 Jan 2026 01:27:58 GMT - selector: pubDate - filters: - - name: replace - args: ["GMT", "+00:00"] - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - category: - selector: '[name="category"]' - attribute: value - size: - selector: size - seeders: - selector: '[name="seeders"]' - attribute: value - leechers: - selector: '[name="leechers"]' - attribute: value - grabs: - selector: '[name="grabs"]' - attribute: value - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nethd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nethd.yml deleted file mode 100644 index 29ce2880..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nethd.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: nethd -name: NetHD -description: "NetHD (VietTorrent) is a VIETNAMESE Semi-Private Torrent Tracker for HD MOVIES / TV" -language: vi-VN -type: semi-private -encoding: UTF-8 -links: - - https://nethd.org/ -legacylinks: - - http://nethd.org/ # site forces https - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: Audio, desc: "Music"} - - {id: 403, cat: PC/Games, desc: "Game"} - - {id: 404, cat: PC, desc: "Software"} - - {id: 405, cat: Other, desc: "Image"} - - {id: 406, cat: Books, desc: "Book"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "If the account is not used regularly within 2 years, the system will automatically delete the account." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: form#loginform > span.warning - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 title, 1 descr, 2 uploader, 4 imdb url - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xFree, 4 50%, 6 2x 50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 incldead, 1 active , 2 dead - incldead: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: tr:has(td.name) - filters: - - name: andmatch - - fields: - category: - selector: td.category > a - attribute: href - filters: - - name: querystring - args: cat - title: - selector: td.name > div > a[href*="-torrent-"][title] - attribute: title - details: - selector: td.name > div > a[href*="-torrent-"][title] - attribute: href - poster: - selector: td.name > a.poster-preview img - attribute: src - download: - selector: td.name > div > a[href*="-torrent-"][title] - attribute: href - filters: - - name: regexp - args: "-torrent-(\\d+).html" - - name: prepend - args: "download.php?id=" - date_year: - # 2021-08-13
09:32:42 - selector: td:nth-child(5):contains("-") - optional: true - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - date_ago: - # 2 ngày
23 giờ - selector: td:nth-child(5):not(:contains("-")) - optional: true - filters: - - name: re_replace - args: ["năm", "years "] - - name: re_replace - args: ["tháng", "months "] - - name: re_replace - args: ["ngày", "days "] - - name: re_replace - args: ["phút", "minutes "] - - name: re_replace - args: ["giờ", "hours "] - - name: timeago - date: - text: "{{ if or .Result.date_year .Result.date_ago }}{{ or .Result.date_year .Result.date_ago }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - case: - "span.label:contains(\"Free\")": 0 - "span.label:contains(\"50%\")": 0.5 - "span.label:contains(\"30%\")": 0.7 - "*": 1 - uploadvolumefactor: - case: - "span.label:contains(\"2X\")": 2 - "*": 1 - minimumratio: - text: 0.5 - description: - selector: td:nth-child(3) > div:nth-child(2) - remove: a, b, font, img, span -# NexusPHP 1.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/new-team.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/new-team.yml deleted file mode 100644 index a30ea2cc..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/new-team.yml +++ /dev/null @@ -1,475 +0,0 @@ ---- -id: new-team -name: New-Team -description: "New-Team is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://new-team.org/ - -caps: - categorymappings: - # Кинематограф, театр, видео - - {id: 241, cat: Movies, desc: "|- Релизы от Scarabey"} - - {id: 1170, cat: Movies, desc: "|-- Зарубежные и российские фильмы 2019 - 2023"} - - {id: 1137, cat: Movies, desc: "|-- ТРЕБУЕТСЯ СИД."} - - {id: 1116, cat: Movies, desc: "|-- Группа 'Сиды' - хранение раздач"} - - {id: 1040, cat: Movies, desc: "|-- Зарубежные и российские фильмы до 1990 года"} - - {id: 1041, cat: Movies, desc: "|-- Зарубежные и российские фильмы 1991-2000"} - - {id: 1042, cat: Movies, desc: "|-- Зарубежные и российские фильмы 2001-2009"} - - {id: 1039, cat: Movies, desc: "|-- Зарубежные и российские фильмы 2010-2013"} - - {id: 1088, cat: Movies, desc: "|-- Зарубежные и российские фильмы 2014-2015"} - - {id: 1112, cat: Movies, desc: "|-- Зарубежные и российские фильмы 2016-2018"} - - {id: 1110, cat: Movies, desc: "|-- Зарубежные и российские фильмы в MKV (AVC / BDRip 720p /..."} - - {id: 1111, cat: Movies/Other, desc: "|-- Аниме"} - - {id: 1051, cat: Movies/Foreign, desc: "|-- Азиатские фильмы"} - - {id: 1035, cat: TV, desc: "|-- Сериалы"} - - {id: 1096, cat: TV, desc: "|--- Сериал '24 часа'"} - - {id: 1092, cat: TV, desc: "|--- Сериал 'Во все тяжкие'"} - - {id: 1094, cat: TV, desc: "|--- Сериал 'Игра престолов'"} - - {id: 1098, cat: TV, desc: "|--- Сериал 'Остаться в живых'"} - - {id: 1097, cat: TV, desc: "|--- Сериал 'Подпольная империя'"} - - {id: 1090, cat: TV, desc: "|--- Сериал 'Спартак'"} - - {id: 1093, cat: TV, desc: "|--- Сериал 'Ходячие мертвецы'"} - - {id: 1095, cat: TV, desc: "|--- Сериал 'Шерлок'"} - - {id: 124, cat: Movies, desc: "|- Кинофильмы"} - - {id: 191, cat: Movies/Foreign, desc: "|-- Зарубежное кино до 1990 года"} - - {id: 192, cat: Movies/Foreign, desc: "|-- Зарубежное кино 1990 - 1999"} - - {id: 160, cat: Movies/Foreign, desc: "|-- Зарубежное кино 2000 - 2009"} - - {id: 125, cat: Movies/Foreign, desc: "|-- Зарубежное кино 2010 - 2019"} - - {id: 1142, cat: Movies/Foreign, desc: "|-- Зарубежное кино 2020 - 2024"} - - {id: 1182, cat: Movies/Foreign, desc: "|-- Зарубежное кино 2025 (Новинки)"} - - {id: 126, cat: Movies/Foreign, desc: "|-- Зарубежное кино (HD и DVD Video)"} - - {id: 162, cat: Movies, desc: "|-- Российское кино"} - - {id: 164, cat: Movies, desc: "|--- Российское кино (HD и DVD Video)"} - - {id: 1144, cat: Movies, desc: "|--- Детское российское кино"} - - {id: 900, cat: Movies, desc: "|-- Советское кино"} - - {id: 1181, cat: Movies, desc: "|--- Советское кино (HD и DVD Video)"} - - {id: 1143, cat: Movies, desc: "|--- Детское советское кино"} - - {id: 1104, cat: Movies, desc: "|--- Фильмы довоенные и созданные во время войны"} - - {id: 1177, cat: Movies, desc: "|--- Телеспектакль"} - - {id: 891, cat: Movies/3D, desc: "|-- Кинофильмы 3D"} - - {id: 338, cat: Movies/Foreign, desc: "|-- Классика зарубежного кино"} - - {id: 337, cat: Movies/Foreign, desc: "|-- Азиатские фильмы"} - - {id: 917, cat: Movies, desc: "|-- Арт-хаус и авторское кино"} - - {id: 207, cat: Movies, desc: "|-- Сборники"} - - {id: 203, cat: Movies/Other, desc: "|-- Трейлеры и дополнительные материалы"} - - {id: 826, cat: Audio/Other, desc: "|-- Звуковые дорожки и переводы"} - - {id: 827, cat: Audio/Other, desc: "|--- Авторский перевод"} - - {id: 828, cat: Audio/Other, desc: "|--- Закадровый перевод"} - - {id: 134, cat: TV/Documentary, desc: "|- Документальные фильмы и телепередачи"} - - {id: 999, cat: TV/Documentary, desc: "|-- Флора и фауна"} - - {id: 254, cat: TV/Documentary, desc: "|-- Великая Отечественная война"} - - {id: 478, cat: TV/Documentary, desc: "|-- Научно-популярные фильмы"} - - {id: 1058, cat: TV/Documentary, desc: "|-- История: Города и Страны / Архитектура / Музеи мира"} - - {id: 136, cat: TV/Documentary, desc: "|-- История: Биографии / Кумиры / Личности / События"} - - {id: 253, cat: TV/Documentary, desc: "|-- Планета Земля / Космос / Вселенная"} - - {id: 256, cat: TV/Documentary, desc: "|-- Вера и религия / Диагностика кармы"} - - {id: 252, cat: TV/Documentary, desc: "|-- Тайны века / Спецслужбы / Теории Заговоров"} - - {id: 255, cat: TV/Documentary, desc: "|-- Развлекательные телепередачи и шоу, приколы и юмор"} - - {id: 135, cat: TV/Documentary, desc: "|-- Документальные фильмы и телепередачи (DVD, HD Video)"} - - {id: 889, cat: TV/Documentary, desc: "|-- Документальные фильмы и телепередачи (3D)"} - # Сериалы - - {id: 845, cat: TV, desc: "|- Отечественные cериалы"} - - {id: 850, cat: TV, desc: "|-- Обсуждение сериалов"} - - {id: 854, cat: TV, desc: "|-- Исторические, военные"} - - {id: 846, cat: TV/Foreign, desc: "|- Зарубежные сериалы"} - - {id: 856, cat: TV/Foreign, desc: "|-- Исторические, военные"} - - {id: 847, cat: TV, desc: "|- Отечественные сериалы (HD, DVD Video)"} - - {id: 1188, cat: TV, desc: "|-- Детектив, боевик, криминал"} - - {id: 857, cat: TV, desc: "|-- Исторические, военные"} - - {id: 848, cat: TV/Foreign, desc: "|- Зарубежные сериалы (HD, DVD Video)"} - - {id: 858, cat: TV/Foreign, desc: "|-- Исторические, военные"} - # Мультипликация - - {id: 869, cat: Movies/Other, desc: "|- Мультфильмы"} - - {id: 1068, cat: Movies/Other, desc: "|- Короткометражные отечественные мультфильмы"} - - {id: 1067, cat: Movies/Other, desc: "|- Короткометражные иностранные мультфильмы"} - - {id: 1066, cat: Movies/Other, desc: "|- Короткометражные мультфильмы (DVD, HD Video)"} - - {id: 871, cat: Movies/Other, desc: "|-- Отечественные Мультфильмы"} - - {id: 870, cat: Movies/Other, desc: "|-- Зарубежные Мультфильмы"} - - {id: 875, cat: Movies/Other, desc: "|-- Отечественные Мультфильмы (DVD, HD Video)"} - - {id: 874, cat: Movies/Other, desc: "|-- Зарубежные Мультфильмы (DVD, HD Video)"} - - {id: 877, cat: Movies/Other, desc: "|-- Сборники отечественных мультфильмов"} - - {id: 876, cat: Movies/Other, desc: "|-- Сборники зарубежных мультфильмов"} - - {id: 890, cat: Movies/Other, desc: "|-- Мультфильмы 3D"} - - {id: 872, cat: TV/Anime, desc: "|- Мультсериалы"} - - {id: 878, cat: TV/Anime, desc: "|-- Отечественные Мультсериалы"} - - {id: 879, cat: TV/Anime, desc: "|-- Зарубежные Мультсериалы"} - - {id: 873, cat: TV/Anime, desc: "|- Аниме"} - - {id: 886, cat: TV/Anime, desc: "|-- Рипы Аниме"} - - {id: 884, cat: TV/Anime, desc: "|-- Аниме (DVD)"} - - {id: 885, cat: TV/Anime, desc: "|-- Аниме (HD Video)"} - - {id: 883, cat: TV/Anime, desc: "|-- Манга"} - # Музыка - - {id: 571, cat: Audio/Lossless, desc: "|- Камерная и сольная инструментальная музыка (lossless)"} - - {id: 567, cat: Audio/Lossless, desc: "|- Вокальная и хоровая музыка (lossless)"} - - {id: 577, cat: Audio, desc: "|- Classical Crossover (lossy и lossless)"} - - {id: 565, cat: Audio, desc: "|- Полные собрания сочинений и многодисковые издания..."} - - {id: 569, cat: Audio/Lossless, desc: "|- Оркестровая музыка (lossless)"} - - {id: 574, cat: Audio/MP3, desc: "|- Вокальная и хоровая музыка (lossy)"} - - {id: 576, cat: Audio/MP3, desc: "|- Камерная и сольная инструментальная музыка (lossy)"} - - {id: 579, cat: Audio/MP3, desc: "|- Восточноевропейский фолк (lossy)"} - - {id: 580, cat: Audio/Lossless, desc: "|- Восточноевропейский фолк (lossless)"} - - {id: 581, cat: Audio/MP3, desc: "|- Западноевропейский фолк (lossy)"} - - {id: 582, cat: Audio/Lossless, desc: "|- Западноевропейский фолк (lossless)"} - - {id: 583, cat: Audio, desc: "|- Klezmer и Еврейский фольклор (lossy и lossless)"} - - {id: 584, cat: Audio/MP3, desc: "|- Этническая музыка Сибири, Средней и Восточной Азии (lossy)"} - - {id: 585, cat: Audio/Lossless, desc: "|- Этническая музыка Сибири, Средней и Восточной Азии..."} - - {id: 587, cat: Audio/Lossless, desc: "|- Этническая музыка Индии (lossless)"} - - {id: 589, cat: Audio/Lossless, desc: "|- Этническая музыка Африки и Ближнего Востока (lossless)"} - - {id: 590, cat: Audio, desc: "|- Этническая музыка Кавказа и Закавказья (lossy и lossless)"} - - {id: 591, cat: Audio/MP3, desc: "|- Этническая музыка Северной и Южной Америки (lossy)"} - - {id: 592, cat: Audio/Lossless, desc: "|- Этническая музыка Северной и Южной Америки (lossless)"} - - {id: 593, cat: Audio, desc: "|- Этническая музыка Австралии, островов Тихого и Индийского..."} - - {id: 595, cat: Audio/Lossless, desc: "|- Country, Bluegrass (lossless)"} - - {id: 608, cat: Audio/Lossless, desc: "|- Nouveau Flamenco, Mediterranean Guitar, Acoustic guitar..."} - - {id: 607, cat: Audio, desc: "|- Nouveau Flamenco, Mediterranean Guitar, Acoustic guitar..."} - - {id: 487, cat: Audio, desc: "|- Fields Recordings, Nature Sounds (Звуки природы)"} - - {id: 481, cat: Audio/Lossless, desc: "|- New Age, Celtic New Age (lossless)"} - - {id: 483, cat: Audio/MP3, desc: "|- New Age, Celtic New Age (lossy)"} - - {id: 1048, cat: Audio/Lossless, desc: "|- Enigmatic, Gregorianic, Ambientpop, Mysticpop (lossless)"} - - {id: 1049, cat: Audio/MP3, desc: "|- Enigmatic, Gregorianic, Ambientpop, Mysticpop (lossy)"} - - {id: 484, cat: Audio/Lossless, desc: "|- Meditative, Healing, Relax & Nature (lossless)"} - - {id: 485, cat: Audio/MP3, desc: "|- Meditative, Healing, Relax & Nature (lossy)"} - - {id: 490, cat: Audio/Lossless, desc: "|- World Music, Ethnicbeat, Worldbeat, World Fusion..."} - - {id: 491, cat: Audio/MP3, desc: "|- World Music, Ethnicbeat, Worldbeat, World Fusion (lossy)"} - - {id: 492, cat: Audio/Lossless, desc: "|- Mantras, Ragas, Kirtans (lossless)"} - - {id: 493, cat: Audio/MP3, desc: "|- Mantras, Ragas, Kirtans (lossy)"} - - {id: 652, cat: Audio/MP3, desc: "|- Отечественный Рэп, Хип-Хоп (lossy)"} - - {id: 653, cat: Audio/MP3, desc: "|- Отечественный R'n'B (lossy)"} - - {id: 654, cat: Audio/Lossless, desc: "|- Отечественный Рэп, Хип-Хоп, R'n'B (lossless)"} - - {id: 657, cat: Audio/MP3, desc: "|- Зарубежный Рэп, Хип-Хоп (lossy)"} - - {id: 659, cat: Audio/Lossless, desc: "|- Зарубежный Рэп, Хип-Хоп (lossless)"} - - {id: 660, cat: Audio/Lossless, desc: "|- Зарубежный R'n'B (lossless)"} - - {id: 756, cat: Audio/Lossless, desc: "|- Ska, Ska-Punk, Ska-Jazz (lossless)"} - - {id: 752, cat: Audio/MP3, desc: "|- Reggae (lossy)"} - - {id: 755, cat: Audio/Lossless, desc: "|- Reggae, Dancehall, Dub (lossless)"} - - {id: 549, cat: Audio, desc: "|- Саундтреки и Караоке"} - - {id: 765, cat: Audio, desc: "|-- Минусовки (lossy и lossless)"} - - {id: 767, cat: Audio/Lossless, desc: "|-- Саундтреки к отечественным фильмам (lossless)"} - - {id: 768, cat: Audio/MP3, desc: "|-- Саундтреки к отечественным фильмам (lossy)"} - - {id: 769, cat: Audio/Lossless, desc: "|-- Саундтреки к зарубежным фильмам (lossless)"} - - {id: 770, cat: Audio/MP3, desc: "|-- Саундтреки к зарубежным фильмам (lossy)"} - - {id: 772, cat: Audio/Lossless, desc: "|-- Саундтреки к играм (lossless)"} - - {id: 776, cat: Audio/Lossless, desc: "|- Отечественный шансон (lossless)"} - - {id: 777, cat: Audio/MP3, desc: "|- Отечественный шансон (lossy)"} - - {id: 778, cat: Audio/MP3, desc: "|- Сборники отечественного шансона (lossy)"} - - {id: 779, cat: Audio/Lossless, desc: "|- Военная песня (lossless)"} - - {id: 780, cat: Audio/MP3, desc: "|- Военная песня (lossy)"} - - {id: 781, cat: Audio/Lossless, desc: "|- Авторская песня (lossless)"} - - {id: 782, cat: Audio/MP3, desc: "|- Авторская песня (lossy)"} - - {id: 952, cat: Audio/Lossless, desc: "|- Зарубежный шансон, Chanson (lossless)"} - - {id: 1175, cat: Audio/Lossless, desc: "|- Итальянская поп-музыка (lossless)"} - - {id: 1174, cat: Audio/Lossless, desc: "|- Латиноамериканская поп-музыка (lossless)"} - - {id: 1173, cat: Audio/Lossless, desc: "|- Easy Listening, Instrumental Pop (lossless)"} - - {id: 836, cat: Audio/MP3, desc: "|- Отечественная поп-музыка (lossy)"} - - {id: 837, cat: Audio/Lossless, desc: "|- Отечественная поп-музыка (lossless)"} - - {id: 838, cat: Audio/MP3, desc: "|- Зарубежная поп-музыка (lossy)"} - - {id: 839, cat: Audio/Lossless, desc: "|- Зарубежная поп-музыка (lossless)"} - - {id: 843, cat: Audio/Lossless, desc: "|- Восточноазиатская поп-музыка (lossless)"} - - {id: 1082, cat: Audio/Lossless, desc: "|- Disco, Italo-Disco, Euro-Disco, Hi-NRG (lossless)"} - - {id: 1081, cat: Audio/Lossless, desc: "|- Eurodance, Euro-House, Technopop (lossless)"} - - {id: 1084, cat: Audio/MP3, desc: "|- Disco, Italo-Disco, Euro-Disco, Hi-NRG (сборники) (lossy)"} - - {id: 1083, cat: Audio/MP3, desc: "|- Eurodance, Euro-House, Technopop (сборники) (lossy)"} - - {id: 1187, cat: Audio, desc: "|- Аудио, как приложение к журналам"} - - {id: 794, cat: Audio, desc: "|- Orthodox spiritual music. Православные песнопения..."} - - {id: 1140, cat: Audio, desc: "|- Аудиоматериал для тестирования и настройки"} - - {id: 1099, cat: Audio/MP3, desc: "|- Профессиональная музыка для йоги и фитнеса (lossy)"} - - {id: 789, cat: Audio/Lossless, desc: "|- Отечественная музыка других жанров (lossless)"} - - {id: 788, cat: Audio/MP3, desc: "|- Отечественная музыка других жанров (lossy)"} - - {id: 791, cat: Audio/Lossless, desc: "|- Зарубежная музыка других жанров (lossless)"} - - {id: 790, cat: Audio/MP3, desc: "|- Зарубежная музыка других жанров (lossy)"} - - {id: 795, cat: Audio, desc: "|- Сборники песен для детей (lossy и lossless)"} - - {id: 798, cat: Audio, desc: "|- Мюзикл (lossy и lossless)"} - - {id: 800, cat: Audio/MP3, desc: "|- Неофициальные и внежанровые сборники (lossy)"} - - {id: 186, cat: Audio/Video, desc: "|- Музыкальное видео (DVD и HD Video)"} - - {id: 185, cat: Audio/Video, desc: "|- Музыкальное видео"} - - {id: 1160, cat: Audio/Foreign, desc: "|- Зарубежная поп-музыка"} - - {id: 1159, cat: Audio, desc: "|- Советская эстрада, ретро, романсы"} - - {id: 1158, cat: Audio, desc: "|- Отечественная поп-музыка"} - - {id: 1157, cat: Audio, desc: "|- Инструментальная поп-музыка, эстрада"} - - {id: 1156, cat: Audio, desc: "|- Джаз и блюз"} - - {id: 1155, cat: Audio/Foreign, desc: "|- Зарубежная рок-музыка"} - - {id: 1154, cat: Audio, desc: "|- Отечественная рок-музыка"} - - {id: 1153, cat: Audio, desc: "|- Электронная музыка"} - - {id: 1152, cat: Audio, desc: "|- Классика и классика в современной обработке"} - # Электронная музыка - - {id: 611, cat: Audio/Lossless, desc: "|- Goa Trance, Psy-Trance (lossless)"} - - {id: 612, cat: Audio/MP3, desc: "|- Goa Trance, Psy-Trance (lossy)"} - - {id: 613, cat: Audio/Lossless, desc: "|- PsyChill, Ambient Dub (lossless)"} - - {id: 614, cat: Audio/MP3, desc: "|- PsyChill, Ambient Dub (lossy)"} - - {id: 616, cat: Audio/Lossless, desc: "|- Trance (lossless)"} - - {id: 617, cat: Audio/MP3, desc: "|- Trance (lossy)"} - - {id: 618, cat: Audio/MP3, desc: "|- Trance (Singles, EPs) (lossy)"} - - {id: 619, cat: Audio/MP3, desc: "|- Trance (Radioshows, Podcasts, Live Sets, Mixes) (lossy)"} - - {id: 1061, cat: Audio/MP3, desc: "|- Trance discography (lossy)"} - - {id: 621, cat: Audio/Lossless, desc: "|- Hardcore, Hardstyle, Jumpstyle (lossless)"} - - {id: 622, cat: Audio/MP3, desc: "|- Hardcore, Hardstyle, Jumpstyle (lossy)"} - - {id: 623, cat: Audio, desc: "|- Hardcore, Hardstyle, Jumpstyle (vinyl, web)"} - - {id: 624, cat: Audio/Lossless, desc: "|- House (lossless)"} - - {id: 625, cat: Audio, desc: "|- House (Radioshow, Podcast, Liveset, Mixes)"} - - {id: 626, cat: Audio/MP3, desc: "|- House (lossy)"} - - {id: 627, cat: Audio, desc: "|- House (Проморелизы, сборники)"} - - {id: 628, cat: Audio/MP3, desc: "|- House (Singles, EPs) (lossy)"} - - {id: 629, cat: Audio/Lossless, desc: "|- Techno (lossless)"} - - {id: 630, cat: Audio/MP3, desc: "|- Techno (lossy)"} - - {id: 631, cat: Audio, desc: "|- Techno (Radioshows, Podcasts, Livesets, Mixes)"} - - {id: 632, cat: Audio/MP3, desc: "|- Techno (Singles, EPs) (lossy)"} - - {id: 643, cat: Audio/Lossless, desc: "|- Dubstep, Future Garage, Bass Music, UK Garage (lossless)"} - - {id: 644, cat: Audio/MP3, desc: "|- Dubstep, Future Garage, Bass Music, UK Garage (lossy)"} - - {id: 634, cat: Audio/Lossless, desc: "|- Electro, Electro-Freestyle, Nu Electro (lossless)"} - - {id: 635, cat: Audio/MP3, desc: "|- Electro, Electro-Freestyle, Nu Electro (lossy)"} - - {id: 636, cat: Audio/Lossless, desc: "|- Drum & Bass, Jungle (lossless)"} - - {id: 637, cat: Audio/MP3, desc: "|- Drum & Bass, Jungle (lossy)"} - - {id: 641, cat: Audio/Lossless, desc: "|- Breakbeat (lossless)"} - - {id: 642, cat: Audio/MP3, desc: "|- Breakbeat (lossy)"} - - {id: 645, cat: Audio/Lossless, desc: "|- IDM (lossless)"} - - {id: 646, cat: Audio/MP3, desc: "|- IDM (lossy)"} - - {id: 802, cat: Audio/Lossless, desc: "|- Chillout, Lounge, Downtempo (lossless)"} - - {id: 803, cat: Audio/MP3, desc: "|- Chillout, Lounge, Downtempo (lossy)"} - - {id: 804, cat: Audio/Lossless, desc: "|- Nu Jazz, Acid Jazz, Future Jazz (lossless)"} - - {id: 805, cat: Audio/MP3, desc: "|- Nu Jazz, Acid Jazz, Future Jazz (lossy)"} - - {id: 806, cat: Audio/Lossless, desc: "|- Trip Hop, Abstract Hip-Hop (lossless)"} - - {id: 807, cat: Audio/MP3, desc: "|- Trip Hop, Abstract Hip-Hop (lossy)"} - - {id: 1070, cat: Audio, desc: "|- 8-bit, Chiptune (lossy & lossless)"} - - {id: 808, cat: Audio/Lossless, desc: "|- Experimental (lossless)"} - - {id: 809, cat: Audio/MP3, desc: "|- Experimental (lossy)"} - - {id: 810, cat: Audio/Lossless, desc: "|- Modern Classical, Electroacoustic (lossless)"} - - {id: 811, cat: Audio/MP3, desc: "|- Modern Classical, Electroacoustic (lossy)"} - - {id: 812, cat: Audio/Lossless, desc: "|- Traditional Electronic, Ambient (lossless)"} - - {id: 813, cat: Audio/MP3, desc: "|- Traditional Electronic, Ambient (lossy)"} - - {id: 815, cat: Audio/Lossless, desc: "|- EBM, Dark Electro, Aggrotech (lossless)"} - - {id: 816, cat: Audio/MP3, desc: "|- EBM, Dark Electro, Aggrotech (lossy)"} - - {id: 817, cat: Audio/Lossless, desc: "|- Industrial, Noise (lossless)"} - - {id: 818, cat: Audio/MP3, desc: "|- Industrial, Noise (lossy)"} - - {id: 1071, cat: Audio/Lossless, desc: "|- Darkwave, Neoclassical, Ethereal, Dungeon Synth (lossless)"} - - {id: 1072, cat: Audio/MP3, desc: "|- Darkwave, Neoclassical, Ethereal, Dungeon Synth (lossy)"} - - {id: 819, cat: Audio/Lossless, desc: "|- Synthpop, Futurepop, New Wave (lossless)"} - - {id: 820, cat: Audio/MP3, desc: "|- Synthpop, Futurepop, New Wave (lossy)"} - - {id: 512, cat: Audio/Lossless, desc: "|- Label Packs (lossless)"} - - {id: 513, cat: Audio/MP3, desc: "|- Label packs, Scene packs, Radioshows packs (lossy)"} - # Рок-музыка - - {id: 524, cat: Audio, desc: "|- Зарубежный Rock"} - - {id: 699, cat: Audio/Lossless, desc: "|-- Pop Rock & Soft Rock (lossless)"} - - {id: 700, cat: Audio/MP3, desc: "|-- Pop Rock & Soft Rock (lossy)"} - - {id: 696, cat: Audio/Lossless, desc: "|-- Folk Rock (lossless)"} - - {id: 1167, cat: Audio/MP3, desc: "|-- Folk Rock (lossy)"} - - {id: 692, cat: Audio/Lossless, desc: "|-- Classic Rock & Hard Rock (lossless)"} - - {id: 693, cat: Audio/MP3, desc: "|-- Classic Rock & Hard Rock (lossy)"} - - {id: 694, cat: Audio/Lossless, desc: "|-- Progressive & Art-Rock (lossless)"} - - {id: 695, cat: Audio/MP3, desc: "|-- Progressive & Art-Rock (lossy)"} - - {id: 697, cat: Audio/Lossless, desc: "|-- AOR (Melodic Hard Rock, Arena rock) (lossless)"} - - {id: 698, cat: Audio/MP3, desc: "|-- AOR (Melodic Hard Rock, Arena rock) (lossy)"} - - {id: 701, cat: Audio/Lossless, desc: "|-- Instrumental Guitar Rock (lossless)"} - - {id: 702, cat: Audio/MP3, desc: "|-- Instrumental Guitar Rock (lossy)"} - - {id: 703, cat: Audio/Lossless, desc: "|-- Rockabilly, Psychobilly, Rock'n'Roll (lossless)"} - - {id: 704, cat: Audio/MP3, desc: "|-- Rockabilly, Psychobilly, Rock'n'Roll (lossy)"} - - {id: 705, cat: Audio/Lossless, desc: "|-- Восточноазиатский рок (lossless)"} - - {id: 706, cat: Audio/MP3, desc: "|-- Восточноазиатский рок (lossy)"} - - {id: 918, cat: Audio/Lossless, desc: "|- Sludge, Stoner, Post-Metal (lossless)"} - - {id: 919, cat: Audio/MP3, desc: "|- Sludge, Stoner, Post-Metal (lossly)"} - - {id: 711, cat: Audio/Lossless, desc: "|- Death, Doom (lossless)"} - - {id: 712, cat: Audio/MP3, desc: "|- Death, Doom (lossy)"} - - {id: 707, cat: Audio/Lossless, desc: "|- Avant-garde, Experimental Metal (lossless)"} - - {id: 708, cat: Audio/MP3, desc: "|- Avant-garde, Experimental Metal (lossy)"} - - {id: 709, cat: Audio/Lossless, desc: "|- Black (lossless)"} - - {id: 710, cat: Audio/MP3, desc: "|- Black (lossy)"} - - {id: 713, cat: Audio/Lossless, desc: "|- Folk, Pagan, Viking (lossless)"} - - {id: 714, cat: Audio/MP3, desc: "|- Folk, Pagan, Viking (lossy)"} - - {id: 715, cat: Audio/Lossless, desc: "|- Gothic Metal (lossless)"} - - {id: 716, cat: Audio/MP3, desc: "|- Gothic Metal (lossy)"} - - {id: 717, cat: Audio/Lossless, desc: "|- Grind, Brutal Death (lossless)"} - - {id: 718, cat: Audio/MP3, desc: "|- Grind, Brutal Death (lossy)"} - - {id: 719, cat: Audio/Lossless, desc: "|- Heavy, Power, Progressive (lossless)"} - - {id: 720, cat: Audio/MP3, desc: "|- Heavy, Power, Progressive (lossy)"} - - {id: 721, cat: Audio/Lossless, desc: "|- Metalcore, Deathcore, Mathcore (lossless)"} - - {id: 722, cat: Audio/MP3, desc: "|- Metalcore, Deathcore, Mathcore (lossy)"} - - {id: 723, cat: Audio/Lossless, desc: "|- Thrash, Speed (lossless)"} - - {id: 724, cat: Audio/MP3, desc: "|- Thrash, Speed (lossy)"} - - {id: 725, cat: Audio/Lossless, desc: "|- Сборники (lossless)"} - - {id: 726, cat: Audio/MP3, desc: "|- Сборники (lossy)"} - - {id: 1122, cat: Audio/Lossless, desc: "|- Gothic Rock, Deathrock, Electrogoth, Dark Cabaret..."} - - {id: 1125, cat: Audio/MP3, desc: "|- Gothic Rock, Deathrock, Electrogoth, Dark Cabaret (lossy)"} - - {id: 1123, cat: Audio/Lossless, desc: "|- Dark Folk, Neofolk, Martial Industrial, Ritual Ambient..."} - - {id: 1124, cat: Audio/MP3, desc: "|- Dark Folk, Neofolk, Martial Industrial, Ritual Ambient..."} - - {id: 727, cat: Audio/Lossless, desc: "|- Alternative & Nu-metal (lossless)"} - - {id: 728, cat: Audio/MP3, desc: "|- Alternative & Nu-metal (lossy)"} - - {id: 729, cat: Audio/Lossless, desc: "|- Punk (lossless)"} - - {id: 730, cat: Audio/MP3, desc: "|- Punk (lossy)"} - - {id: 731, cat: Audio/Lossless, desc: "|- Hardcore (lossless)"} - - {id: 732, cat: Audio/MP3, desc: "|- Hardcore (lossy)"} - - {id: 733, cat: Audio/Lossless, desc: "|- Indie, Post-Rock & Post-Punk (lossless)"} - - {id: 734, cat: Audio/MP3, desc: "|- Indie, Post-Rock & Post-Punk (lossy)"} - - {id: 735, cat: Audio/Lossless, desc: "|- Industrial & Post-industrial (lossless)"} - - {id: 736, cat: Audio/MP3, desc: "|- Industrial & Post-industrial (lossy)"} - - {id: 737, cat: Audio/Lossless, desc: "|- Emocore, Post-hardcore (lossless)"} - - {id: 738, cat: Audio/MP3, desc: "|- Emocore, Post-hardcore (lossy)"} - - {id: 1074, cat: Audio/MP3, desc: "|- Сборники (lossy)"} - - {id: 742, cat: Audio/Lossless, desc: "|- Рок, Панк, Альтернатива (lossless)"} - - {id: 743, cat: Audio/MP3, desc: "|- Рок, Панк, Альтернатива (lossy)"} - - {id: 744, cat: Audio/Lossless, desc: "|- Металл (lossless)"} - - {id: 745, cat: Audio/MP3, desc: "|- Металл (lossy)"} - - {id: 746, cat: Audio/Lossless, desc: "|- Рок на языках народов xUSSR (lossless)"} - - {id: 747, cat: Audio/MP3, desc: "|- Рок на языках народов xUSSR (lossy)"} - # Джазовая и блюзовая музыка - - {id: 670, cat: Audio/Lossless, desc: "|- World Jazz, Ethnic Jazz (lossless)"} - - {id: 666, cat: Audio/Lossless, desc: "|- Early Jazz, Swing, Gypsy (lossless)"} - - {id: 667, cat: Audio/Lossless, desc: "|- Bop (lossless)"} - - {id: 668, cat: Audio/Lossless, desc: "|- Mainstream Jazz, Cool (lossless)"} - - {id: 669, cat: Audio/Lossless, desc: "|- Jazz Fusion (lossless)"} - - {id: 671, cat: Audio/Lossless, desc: "|- Avant-Garde Jazz, Free Improvisation (lossless)"} - - {id: 672, cat: Audio/Lossless, desc: "|- Modern Creative, Third Stream (lossless)"} - - {id: 673, cat: Audio/Lossless, desc: "|- Smooth, Jazz-Pop (lossless)"} - - {id: 674, cat: Audio/Lossless, desc: "|- Vocal Jazz (lossless)"} - - {id: 675, cat: Audio/Lossless, desc: "|- Funk, Soul, R&B (lossless)"} - - {id: 677, cat: Audio/MP3, desc: "|- Зарубежный джаз (lossy)"} - - {id: 680, cat: Audio/Lossless, desc: "|- Blues Rock (lossless)"} - - {id: 679, cat: Audio/Lossless, desc: "|- Blues (Texas, Chicago, Modern and Others) (lossless)"} - - {id: 681, cat: Audio/Lossless, desc: "|- Roots, Pre-War Blues, Early R&B, Gospel (lossless)"} - - {id: 683, cat: Audio/MP3, desc: "|- Зарубежный блюз (lossy)"} - - {id: 686, cat: Audio/Lossless, desc: "|- Отечественный джаз (lossless)"} - - {id: 687, cat: Audio/MP3, desc: "|- Отечественный джаз (lossy)"} - - {id: 688, cat: Audio/Lossless, desc: "|- Отечественный блюз (lossless)"} - - {id: 689, cat: Audio/MP3, desc: "|- Отечественный блюз (lossy)"} - # Программное обеспечение - - {id: 68, cat: PC, desc: "|- Программы под Windows"} - - {id: 91, cat: PC, desc: "|-- Системы для бизнеса"} - - {id: 95, cat: PC, desc: "|-- Программы для работы со звуком"} - - {id: 94, cat: PC, desc: "|-- Редакторы видео"} - - {id: 87, cat: PC, desc: "|-- 3D моделирование, рендеринг и плагины для них"} - - {id: 98, cat: PC, desc: "|-- Системы навигации и карты"} - # Книги и журналы - - {id: 340, cat: Books, desc: "|- Предложения по улучшению раздела 'Книги и журналы..."} - - {id: 341, cat: Books, desc: "|- Художественная литература"} - - {id: 351, cat: Books, desc: "|-- Русская литература (Классика)"} - - {id: 405, cat: Books, desc: "|-- Русская литература (Современная проза)"} - - {id: 352, cat: Books/Foreign, desc: "|-- Зарубежная литература (до 1900 г.)"} - - {id: 353, cat: Books/Foreign, desc: "|-- Зарубежная литература (XX и XXI век)"} - - {id: 354, cat: Books, desc: "|-- Детектив / боевик / триллер"} - - {id: 355, cat: Books, desc: "|-- Женский роман"} - - {id: 315, cat: Books/Foreign, desc: "|-- Зарубежная фантастика / фэнтези / мистика"} - - {id: 316, cat: Books, desc: "|-- Отечественная фантастика / фэнтези / мистика"} - - {id: 356, cat: Books, desc: "|-- Приключения"} - - {id: 358, cat: Books/Foreign, desc: "|-- Художественная литература на иностранных языках"} - - {id: 365, cat: Books, desc: "|- Учебная литература для детского сада и начальной шко..."} - - {id: 359, cat: Books, desc: "|- Художественная литературара для средних и старших классов"} - - {id: 343, cat: Books, desc: "|- Гуманитарные науки, история, философия"} - - {id: 378, cat: Books, desc: "|-- Исторические науки. Издания 1992-по н.в."} - - {id: 377, cat: Books, desc: "|-- История России"} - - {id: 376, cat: Books, desc: "|-- Эпоха СССР"} - - {id: 375, cat: Books, desc: "|-- История отдельных стран и регионов"} - - {id: 371, cat: Books, desc: "|-- Философия"} - - {id: 368, cat: Books, desc: "|-- Психотерапия, психодиагностика, консультирование и психокоррекция"} - - {id: 367, cat: Books, desc: "|-- Эзотерика, саморазвитие и самосовершенствование"} - - {id: 382, cat: Books, desc: "|- Электроника / радио"} - - {id: 345, cat: Books, desc: "|- Компьютерная литература"} - - {id: 394, cat: Books, desc: "|-- Веб-дизайн и программирование"} - - {id: 393, cat: Books, desc: "|-- Программирование"} - - {id: 898, cat: Books, desc: "|-- СУБД"} - - {id: 392, cat: Books, desc: "|-- Графика, обработка видео"} - - {id: 391, cat: Books, desc: "|-- Сети / VoIP"} - - {id: 389, cat: Books, desc: "|-- Компьютерные журналы и приложения к ним"} - - {id: 396, cat: Books/Other, desc: "|- Прочее"} - - {id: 349, cat: Books/Mags, desc: "|- Журналы и газеты (общий раздел)"} - - {id: 350, cat: Books/Other, desc: "|- Разное"} - - {id: 342, cat: Books, desc: "|-- Учебная литература - для детей, родителей и учителей"} - - {id: 370, cat: Books, desc: "|-- Экономика, бизнес, менеджмент, маркетинг"} - - {id: 398, cat: Books, desc: "|-- Прочие хобби"} - - {id: 399, cat: Books, desc: "|-- Кулинария (книги)"} - - {id: 400, cat: Books, desc: "|-- Охота и рыбалка"} - - {id: 336, cat: Books, desc: "|-- Физкультура, фитнес, бодибилдинг, йога"} - # Разное - - {id: 242, cat: Other, desc: "|- Другое"} - # Скрытые разделы - - {id: 1184, cat: Other, desc: "|- Для оформления релизов AVP Studio"} - - modes: - search: [q] - tv-search: [q] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: login.php - method: form - form: form[action="./login.php"] - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - redirect: "index.php" - login: Вход - error: - - selector: table.error - test: - path: index.php - selector: a[href^="login.php?logout=1"] - -search: - paths: - - path: tracker.php - inputs: - f: "{{ join .Categories \",\" }}" - nm: "{{ .Keywords }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tm: -1 - - rows: - selector: tr[id^="tor_"]:has(a[href^="/download.php?id="]) - - fields: - category: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.tLink - details: - selector: a.tLink - attribute: href - download: - selector: a[href^="/download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - selector: td:last-child > u - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newheaven.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newheaven.yml deleted file mode 100644 index 530e10fb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newheaven.yml +++ /dev/null @@ -1,236 +0,0 @@ ---- -id: newheaven -name: New Heaven -description: "New Heaven is a GERMAN Private tracker for MOVIES / AUDIO / TV / GENERAL" -language: de-DE -type: private -encoding: iso-8859-1 -requestDelay: 2 -links: - - https://newheaven.nl/ -legacylinks: - - https://torrentheaven.myfqdn.info/ - -caps: - categorymappings: - - {id: 1, cat: PC/Games, desc: GAMES- PC} - - {id: 3, cat: Console, desc: GAMES - Sonstige} - - {id: 59, cat: Console/PS4, desc: GAMES - PlayStation} - - {id: 60, cat: Console/PSP, desc: GAMES- PSP} - - {id: 63, cat: Console/Wii, desc: GAMES - Wii} - - {id: 67, cat: Console/XBox 360, desc: GAMES - XBOX 360} - - {id: 68, cat: PC/Mobile-Other, desc: GAMES - PDA / Handy} - - {id: 72, cat: Console/NDS, desc: GAMES - NDS} - - {id: 7, cat: Movies/DVD, desc: MOVIES - DVD} - - {id: 8, cat: Movies/SD, desc: MOVIES - SD} - - {id: 37, cat: Movies/DVD, desc: MOVIES - DVD Spezial} - - {id: 41, cat: Movies/Foreign, desc: MOVIES - International} - - {id: 101, cat: Movies/HD, desc: MOVIES - 720p} - - {id: 102, cat: Movies/HD, desc: MOVIES - 1080p} - - {id: 103, cat: Movies/HD, desc: MOVIES - AVCHD} - - {id: 104, cat: Movies/BluRay, desc: MOVIES - Blu Ray} - - {id: 106, cat: Movies/3D, desc: MOVIES - 3D} - - {id: 109, cat: Movies/UHD, desc: MOVIES - 4K} - - {id: 14, cat: Audio/MP3, desc: AUDIO - Musik} - - {id: 15, cat: Audio/Audiobook, desc: AUDIO - Hoerbuecher} - - {id: 16, cat: Audio/Audiobook, desc: AUDIO - Hoerspiele} - - {id: 36, cat: Audio/Lossless, desc: AUDIO - Flac} - - {id: 42, cat: Audio/Other, desc: AUDIO/Soundtracks} - - {id: 58, cat: Audio/Video, desc: AUDIO - Musikvideos} - - {id: 18, cat: TV/SD, desc: TV/Serien - Serien SD} - - {id: 19, cat: TV/HD, desc: TV/Serien - Serien HD 720p} - - {id: 20, cat: TV/HD, desc: TV/Serien - Serien HD 1080p} - - {id: 49, cat: TV/SD, desc: TV/Serien - Serien DVD} - - {id: 51, cat: TV/Documentary, desc: TV/Serien - Doku SD} - - {id: 52, cat: TV/Documentary, desc: TV/Serien - Doku HD} - - {id: 53, cat: TV, desc: TV/Serien - Serien Complete Packs} - - {id: 54, cat: TV/Sport, desc: TV/Serien - Sport} - - {id: 66, cat: TV/Foreign, desc: TV/Serien - International} - - {id: 110, cat: TV/UHD, desc: TV/Serien - 4K} - - {id: 22, cat: Books/EBook, desc: MISC - EBooks} - - {id: 24, cat: Other, desc: MISC - Sonstiges} - - {id: 25, cat: Other, desc: MISC- Tonspuren} - - {id: 108, cat: TV/Anime, desc: TV/Serien - Anime} - - {id: 28, cat: PC, desc: APPLICATIONS - PC} - - {id: 29, cat: PC/Mobile-Other, desc: APPLICATIONS - Mobile} - - {id: 30, cat: PC, desc: APPLICATIONS - Sonstige} - - {id: 70, cat: PC, desc: APPLICATIONS - Linux} - - {id: 71, cat: PC/Mac, desc: APPLICATIONS - Mac} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: info_general - type: info - label: Layout Manager Settings - default: "Go to Usercenter->Einstellungen, scroll down to Layout Manager and ensure Comments-, Files- and Snatcher-Anzeige are set to anzeigen, so that the number of files, grabs, seeders and leechers can be correctly detected." - # using cookie method because I could not get the form method to work when converting broken C# indexer to yaml #15527 - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: onlyupload - type: checkbox - label: Filter OnlyUpload only - default: false - - name: info_free - type: info - label: About Freeleech and OnlyUpload at NewHeaven - default: "
  • FreeLeech are torrents where neither the download or upload is counted. (On the Jackett dashboard search results these are tagged as NoUpload).
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech).
" - - name: sort - type: select - label: Sort requested from site - default: torrent_added - options: - torrent_added: created - torrent_seeder: seeders - torrent_size: size - torrent_name: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - n: none - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="index.php?strWebValue=account&strWebAction=logout"] - -search: - paths: - # 25 rows per page - - path: index.php - inputs: - page: 0 - - path: index.php - inputs: - page: 1 - - path: index.php - inputs: - page: 2 - - path: index.php - inputs: - page: 3 - - inputs: - strWebValue: torrent - strWebAction: search - sort: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - $raw: "{{ range .Categories }}dirs{{.}}=1&{{end}}" - searchstring: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 inactive, 2 all - type: 2 - do_search: suchen - # 0 any, 1 1day, 7 1week, 30 30days, 90 90days - time: 0 - # title, nfo, descr, all - details: "{{ if .Query.IMDBID }}all{{ else }}title{{ end }}" - - rows: - selector: "table.torrenttable > tbody > tr:not(:has(td.torrenttableheader)){{ if .Config.onlyupload }}:has(img[src$=\"/onlyup.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="index.php?strWebValue=torrent&strWebAction=search&dir="] - attribute: href - filters: - - name: querystring - args: dir - title_default: - selector: a[href*="index.php?strWebValue=torrent&strWebAction=details&id="] - title_optional: - selector: a[href*="index.php?strWebValue=torrent&strWebAction=details&id="][onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "return buildTable\\('([^']+)'," - optional: true - title: - text: "{{ if .Result.title_optional }}{{ .Result.title_optional }}{{ else }}{{ .Result.title_default }}{{ end }}" - details: - selector: a[href*="index.php?strWebValue=torrent&strWebAction=details&id="] - attribute: href - _id: - selector: a[href*="index.php?strWebValue=torrent&strWebAction=details&id="] - attribute: href - filters: - - name: querystring - args: id - download: - text: "index.php?strWebValue=torrent&strWebAction=download&id={{ .Result._id }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - filters: - - name: querystring - args: url - # 2 flavours of dates - date_day: - # Heute 13:30 - # Gestern 09:10 - selector: font:has(a):contains("Heute"), font:has(a):contains("Gestern") - optional: true - filters: - - name: regexp - args: "((Heute|Gestern)(\\s\\d{2}\\:\\d{2}))" - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 16.04.2021 20:01 - selector: font:has(a):not(:contains("Heute")):not(:contains("Gestern")) - optional: true - filters: - - name: regexp - args: "(\\d{2}\\.\\d{2}.\\d{4}\\s\\d{2}\\:\\d{2})" - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - files: - selector: a[href$="#filelist"] - size: - selector: td:nth-child(4) - grabs: - selector: td:nth-child(7) - seeders: - selector: a[href$="#seeders"] - optional: true - default: 0 - leechers: - selector: a[href$="#leechers"] - optional: true - default: 0 - downloadvolumefactor: - case: - div:contains("50% DL"): 0.5 - div:contains("25% DL"): 0.25 - img[src$="/onlyup.png"]: 0 # only upload is counted - "*": 1 - uploadvolumefactor: - case: - img[src$="/freeleech.png"]: 0 # nothing is counted - div:contains("200% UP"): 2 - "*": 1 - minimumratio: - text: 0.8 -# Aeonflux diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newretro.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newretro.yml deleted file mode 100644 index f48f6666..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newretro.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: newretro -name: The New Retro -description: "The New Retro is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://new-retro.eu/ - -caps: - categorymappings: - - {id: 119, cat: Other, desc: "Bilder"} - - {id: 139, cat: TV/Documentary, desc: "Doku - HD"} - - {id: 141, cat: TV/Documentary, desc: "Doku - Pack"} - - {id: 140, cat: TV/Documentary, desc: "Doku - SD"} - - {id: 142, cat: TV, desc: "Doku - Sonstige"} - - {id: 120, cat: Books, desc: "Ebook"} - - {id: 131, cat: TV/Anime, desc: "Filme - Anime"} - - {id: 101, cat: Movies/BluRay, desc: "Filme - Blue Ray"} - - {id: 102, cat: Movies/Other, desc: "Filme - Bollywood"} - - {id: 103, cat: Movies/DVD, desc: "Filme - DVD"} - - {id: 104, cat: Movies/DVD, desc: "Filme - DVD-R"} - - {id: 135, cat: Movies/HD, desc: "Filme - HD"} - - {id: 130, cat: Movies, desc: "Filme - Klassiker"} - - {id: 134, cat: Movies/SD, desc: "Filme - SD"} - - {id: 136, cat: Movies/UHD, desc: "Filme - UHD"} - - {id: 105, cat: Movies, desc: "Filme - x264/x265"} - - {id: 106, cat: Movies/SD, desc: "Filme - XviD / DivX"} - - {id: 127, cat: Other, desc: "Für Unsere kleinsten"} - - {id: 121, cat: Other, desc: "Handy Stuff"} - - {id: 124, cat: Audio, desc: "Musik - Alben"} - - {id: 123, cat: Audio, desc: "Musik - Discography"} - - {id: 122, cat: Audio/Audiobook, desc: "Musik - Hörbuch"} - - {id: 133, cat: Audio/MP3, desc: "Musik - MP3/Mp4"} - - {id: 125, cat: Audio/Video, desc: "Musik - Video"} - - {id: 113, cat: PC, desc: "Programme - Linux"} - - {id: 114, cat: PC/Mac, desc: "Programme - Mac"} - - {id: 115, cat: PC/0day, desc: "Programme - Windows"} - - {id: 137, cat: TV/HD, desc: "Serien - HD"} - - {id: 116, cat: TV, desc: "Serien - Pack"} - - {id: 138, cat: TV/SD, desc: "Serien - SD"} - - {id: 143, cat: TV/Other, desc: "Serien - Sonstige"} - - {id: 144, cat: TV/UHD, desc: "Serien - UHD"} - - {id: 129, cat: Other, desc: "Sonstiges"} - - {id: 109, cat: PC/Mobile-Other, desc: "Spiele - Handy"} - - {id: 112, cat: Console, desc: "Spiele - Konsolen"} - - {id: 111, cat: PC/Games, desc: "Spiele - Mac / Linux"} - - {id: 110, cat: PC/Games, desc: "Spiele - PC"} - - {id: 118, cat: TV/Sport, desc: "WWE - Sport"} - - {id: 69, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: onlyupload - type: checkbox - label: Filter OnlyUpload only - default: false - - name: info_free - type: info - label: About OnlyUpload at The New Retro - default: "
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Prowlarr search results page these are tagged as Freeleech).
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents pro Seite: setting to 100 on your UserCP page. The default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts are automatically deleted after 42 days." - - name: info_seedhours - type: info - label: Seeding Hours - default: Before using this indexer, make sure that you have set Meine Seedzeit on your UserCP page. - -login: - path: login.php - method: form - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - secure_pin: "{{ .Config.pin }}" - returnto: / - error: - - selector: table.tableinborder:contains("Anmeldung Gescheitert!") > tbody > tr > td.tablea - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - showsearch: 1 - # 0 active, 1 all, 2 dead - incldead: 1 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # site does not support imdbid searching or display imdb links in results. - - rows: - selector: "table.tableinborder[border=\"0\"][cellspacing=\"1\"][cellpadding=\"0\"][style=\"width:100%\"] > tbody > tr:has(a[href^=\"download.php?torrent=\"]){{ if .Config.onlyupload }}:has(img[src=\"pic/oupic.gif\"]){{ else }}{{ end }}" - filters: - - name: andmatch - - fields: - category_optional: - selector: a[href^="browse.php?cat="] - attribute: href - optional: true - filters: - - name: querystring - args: cat - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}129{{ end }}" - title: - selector: a[href^="details.php?id="]:has(b) - details: - selector: a[href^="details.php?id="]:has(b) - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - size: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(1) > b:nth-child(1) - grabs: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(3) > b - files: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(1) > b:nth-child(2) - seeders: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(2) > b:nth-child(1) - leechers: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(2) > b:nth-child(3) - date: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(5) - filters: - - name: replace - args: ["\u00a0", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/oupic.gif"]: 0 # only upload is counted - "*": 1 - uploadvolumefactor: - case: - img[src="pic/freeleech.gif"]: 0 # nothing is counted - "*": 1 -# NetVision diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newstudio.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newstudio.yml deleted file mode 100644 index 4f74a11e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newstudio.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: newstudio -name: NewStudio -description: "NewStudio is a RUSSIAN Public site for TV" -language: ru-RU -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://newstudio.tv/ -legacylinks: - - http://newstudio.tv/ - -caps: - categorymappings: - - {id: 6, cat: TV, desc: "TV series"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -search: - paths: - - path: tracker.php - - path: tracker.php - inputs: - start: 50 - inputs: - nm: "{{ .Keywords }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: table.well > tbody > tr:has(a[href^="./viewtopic.php?t="]) - filters: - - name: andmatch - - fields: - category: - # add category for Sonarr - text: 6 - title: - selector: a[href^="./viewtopic.php?t="] > b - filters: - - name: re_replace - args: ["(?i)Сезон\\s*(\\d+).+Серия\\s*(\\d+)", "S$1E$2"] - - name: re_replace - args: ["(?i)Сезон\\s*(\\d+)", "S$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - - name: append - args: "-NewStudio" - details: - selector: a[href^="./viewtopic.php?t="] - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - poster: - selector: td:nth-child(3) > a > img - attribute: src - size: - selector: td:nth-child(5) > a - date: - # 16-Nov-17, Today 01:34 - # 16-Ноя-17, Сегодня 12:34 - # 15-Nov-17, Yesterday 17:20 - # 15-Ноя-17, Вчера 12:40 - # 4-Nov-17, 23:50 - # 14-Ноя-17, 06:11 - selector: td:nth-child(6) - filters: - # replace white spaces - - name: re_replace - args: ["(Сегодня|Вчера|\\s|,|-)+", " "] - - name: re_replace - args: ["(Today|Yesterday|\\s|,|-)+", " "] - # replace month abbreviations - - name: replace - args: ["Янв", "Jan"] - - name: replace - args: ["Фев", "Feb"] - - name: replace - args: ["Мар", "Mar"] - - name: replace - args: ["Апр", "Apr"] - - name: replace - args: ["Май", "May"] - - name: replace - args: ["Июн", "Jun"] - - name: replace - args: ["Июл", "Jul"] - - name: replace - args: ["Авг", "Aug"] - - name: replace - args: ["Сен", "Sep"] - - name: replace - args: ["Окт", "Oct"] - - name: replace - args: ["Ноя", "Nov"] - - name: replace - args: ["Дек", "Dec"] - - name: append - args: ":00 +03:00" # MSK - - name: dateparse - args: "d MMM yy HH:mm:ss zzz" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="./viewtopic.php?t="] > b -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newstudiol.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newstudiol.yml deleted file mode 100644 index 3855a589..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/newstudiol.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: newstudiol -name: NewStudioL -description: "NewStudio is a RUSSIAN Public site for TV. This supports login." -language: ru-RU -type: semi-private -encoding: UTF-8 -requestDelay: 2 -links: - - https://newstudio.tv/ -legacylinks: - - http://newstudio.tv/ - -caps: - categorymappings: - - {id: 6, cat: TV, desc: "TV series"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech_50 - type: checkbox - label: Search freeleech and 50% freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: login.php - method: post - inputs: - login: 1 - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - error: - - selector: div.alert-error - test: - path: / - selector: a[href="/login.php?logout=1"] - -search: - paths: - - path: tracker.php - - path: tracker.php - inputs: - start: 50 - inputs: - nm: "{{ .Keywords }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tor_type: "{{ if .Config.freeleech_50 }}1{{ else }}{{ end }}" - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: table.well > tbody > tr:has(a[href^="./viewtopic.php?t="]) - filters: - - name: andmatch - - fields: - category: - # add category for Sonarr - text: 6 - title: - selector: a[href^="./viewtopic.php?t="] > b - filters: - - name: re_replace - args: ["(?i)Сезон\\s*(\\d+).+Серия\\s*(\\d+)", "S$1E$2"] - - name: re_replace - args: ["(?i)Сезон\\s*(\\d+)", "S$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - - name: append - args: "-NewStudio" - details: - selector: a[href^="./viewtopic.php?t="] - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - poster: - selector: td:nth-child(3) > a > img - attribute: src - size: - selector: td:nth-child(5) > a - date: - # 16-Nov-17, Today 01:34 - # 16-Ноя-17, Сегодня 12:34 - # 15-Nov-17, Yesterday 17:20 - # 15-Ноя-17, Вчера 12:40 - # 4-Nov-17, 23:50 - # 14-Ноя-17, 06:11 - selector: td:nth-child(6) - filters: - # replace white spaces - - name: re_replace - args: ["(Сегодня|Вчера|\\s|,|-)+", " "] - - name: re_replace - args: ["(Today|Yesterday|\\s|,|-)+", " "] - # replace month abbreviations - - name: replace - args: ["Янв", "Jan"] - - name: replace - args: ["Фев", "Feb"] - - name: replace - args: ["Мар", "Mar"] - - name: replace - args: ["Апр", "Apr"] - - name: replace - args: ["Май", "May"] - - name: replace - args: ["Июн", "Jun"] - - name: replace - args: ["Июл", "Jul"] - - name: replace - args: ["Авг", "Aug"] - - name: replace - args: ["Сен", "Sep"] - - name: replace - args: ["Окт", "Oct"] - - name: replace - args: ["Ноя", "Nov"] - - name: replace - args: ["Дек", "Dec"] - - name: append - args: ":00 +03:00" # MSK - - name: dateparse - args: "d MMM yy HH:mm:ss zzz" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - img[src="images/tor_silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="./viewtopic.php?t="] > b -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nicept.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nicept.yml deleted file mode 100644 index ab5c72c6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nicept.yml +++ /dev/null @@ -1,180 +0,0 @@ ---- -id: nicept -name: NicePT -description: "NicePT is a CHINESE Private Torrent Tracker for 3X" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.nicept.net/ - -caps: - categorymappings: - - {id: 500, cat: XXX, desc: "日本有码"} - - {id: 401, cat: XXX, desc: "日本无码"} - - {id: 402, cat: XXX, desc: "欧美"} - - {id: 501, cat: XXX, desc: "其他(限制级)"} - - {id: 403, cat: XXX/Other, desc: "动漫(限制级)"} - - {id: 503, cat: XXX, desc: "真人秀,自拍(限制级)"} - - {id: 404, cat: XXX/ImageSet, desc: "写真、套图"} - - {id: 504, cat: XXX, desc: "SM调教(限制级)"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran Users users and above will be retained forever
  2. Elite Users and above will not have their account deleted after parking (in the Control Panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be banned if they do not log in for 40 consecutive days
  5. Users with no traffic (ie, upload/download data are both 0) will be banned if they do not log in for 7 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (unused) - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nipponsei.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nipponsei.yml deleted file mode 100644 index fb9d776e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nipponsei.yml +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: nipponsei -name: Nipponsei -description: "Nipponsei is a Public site for MUSIC fresh from JAPAN" -language: en-US -type: public -encoding: UTF-8 -links: - - https://nipponsei.minglong.org/ - -caps: - categorymappings: - - {id: music, cat: Audio, desc: Music} - - modes: - search: [q] - music-search: [q] - -settings: [] - -search: - # https://nipponsei.minglong.org/index.php?section=Tracker&search=Kanojo+mo+Kanojo+ED+Single - paths: - - path: index.php - inputs: - section: Tracker - search: "{{ .Keywords }}" - - rows: - selector: tr.odd, tr.even - - fields: - category: - text: music - title: - selector: a - filters: - - name: replace - args: ["[Nipponsei] ", ""] - details: - text: / - download: - selector: a - attribute: href - date: - selector: td.date - optional: true - default: now - filters: - - name: dateparse - args: "yyyy-MM-dd HH:mm" - size: - selector: td.bytes - optional: true - default: 128 MB - seeders: - selector: "{{ if .Keywords }}td:nth-child(2){{ else }}td:nth-child(4){{ end }}" - leechers: - selector: "{{ if .Keywords }}td:nth-child(3){{ else }}td:nth-child(5){{ end }}" - grabs: - selector: td.number - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nirvana.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nirvana.yml deleted file mode 100644 index 543ca242..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nirvana.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: nirvana -name: Nirvana (API) -description: "Nirvana is a Private RUSSIAN Tracker for MOVIES / TV" -language: ru-RU -type: private -encoding: UTF-8 -links: - - https://nirvana.li/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Фильмы"} - - {id: 2, cat: TV, desc: "Сериалы"} - - {id: 6, cat: Movies/Other, desc: "Аниме-фильмы"} - - {id: 7, cat: TV/Anime, desc: "Аниме-сериалы"} - - {id: 8, cat: Movies/Other, desc: "35mm"} - - {id: 4, cat: Other, desc: "No"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Nirvana account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive users are regularly deleted. To keep an account, you must visit the site at least once a season." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.1.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/njtupt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/njtupt.yml deleted file mode 100644 index 4bfccc4c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/njtupt.yml +++ /dev/null @@ -1,203 +0,0 @@ ---- -id: njtupt -name: njtupt (浦园) -description: "njtupt (浦园) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://njtupt.top/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影/Movies"} - - {id: 402, cat: TV, desc: "剧集/TV-Series"} - - {id: 403, cat: TV, desc: "综艺/TV-Show"} - - {id: 405, cat: TV/Anime, desc: "动漫/Anime"} - - {id: 406, cat: Audio, desc: "音乐/Music"} - - {id: 422, cat: Audio/Video, desc: "演出/MusicVideo"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentary"} - - {id: 407, cat: TV/Sport, desc: "体育/Sports"} - - {id: 415, cat: TV, desc: "资料/Education"} - - {id: 425, cat: PC/Games, desc: "游戏/Game"} - - {id: 428, cat: PC/0day, desc: "软件/Software"} - - {id: 409, cat: Other, desc: "其他/Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the njtupt (浦园) Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account Retention Rules:
  1. Veteran User or above would never be deleted; (Not enabled for the time being).
  2. Elite User or above would never be deleted if parked (at User CP); (Not enabled for the time being).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.15 2024-11-27 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nobs.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nobs.yml deleted file mode 100644 index 2aa15a6f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nobs.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: nobs -name: NOBS -description: "NOBS (NUCLEAR ORDER BIT SYNDICATE) is a SPANISH Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://nobs.rawsmoke.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your NOBS account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }} Spanish" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/noname-club.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/noname-club.yml deleted file mode 100644 index 3e15e0bf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/noname-club.yml +++ /dev/null @@ -1,895 +0,0 @@ ---- -id: noname-club -replaces: - - nnm-club -name: NoNaMe Club -description: "NoNaMe Club (NNM-Club) is a RUSSIAN Public Tracker for MOVIES / TV / MUSIC" -language: ru-RU -type: public -encoding: windows-1251 -links: - - https://nnmclub.to/ -legacylinks: - - https://nnm-club.name/ - - https://nnm-club.me/ - - http://nnmclub.to/ - -caps: - categorymappings: - # forum - - {id: 48, cat: Other, desc: "Форум Региональные встречи"} - # forum tracker - - {id: 925, cat: Other, desc: "Форум-Трекер: Клубные таланты Авторские релизы"} - - {id: 872, cat: Other, desc: "Форум-Трекер: Клубные таланты Архив"} - # Everything for children and parents - - {id: 724, cat: TV, desc: "Видео, Кино и Сериалы для детей и родителей"} - - {id: 725, cat: TV, desc: " |- Обучающее Видео для родителей"} - - {id: 729, cat: TV, desc: " |- Развивающее Видео для детей"} - - {id: 731, cat: TV, desc: " |- Отечественные Фильмы и Сериалы для детей (SD)"} - - {id: 1345, cat: TV, desc: " |- Отечественные Фильмы и Сериалы для детей (HD, FHD, UHD)"} - - {id: 733, cat: TV, desc: " |- Зарубежные Фильмы и Сериалы для детей (SD)"} - - {id: 1346, cat: TV, desc: " |- Зарубежные Фильмы и Сериалы для детей (HD, FHD, UHD)"} - - {id: 1329, cat: TV, desc: " |- Отечественные Мультфильмы 20-го века (SD)"} - - {id: 1330, cat: TV, desc: " |- Отечественные Мультфильмы 20-го века (HD, FHD, UHD)"} - - {id: 1331, cat: TV, desc: " |- Отечественные Мультфильмы 21-го века (SD)"} - - {id: 1332, cat: TV, desc: " |- Отечественные Мультфильмы 21-го века (HD, FHD, UHD, 3D)"} - - {id: 1340, cat: TV, desc: " |- Отечественные Мультфильмы (коллекции, сборники / *логии)"} - - {id: 658, cat: TV, desc: " |- Отечественные Мультсериалы"} - - {id: 890, cat: TV, desc: " |- Мультфильмы (3D)"} - - {id: 1336, cat: TV, desc: " |- Зарубежные Мультфильмы 20-го века (SD)"} - - {id: 1337, cat: TV, desc: " |- Зарубежные Мультфильмы 20-го века (HD, FHD, UHD)"} - - {id: 1338, cat: TV, desc: " |- Зарубежные Мультфильмы 21-го века (SD)"} - - {id: 1339, cat: TV, desc: " |- Зарубежные Мультфильмы 21-го века (HD, FHD, UHD)"} - - {id: 660, cat: TV, desc: " |- Зарубежные Мультфильмы (коллекции, сборники / *логии)"} - - {id: 232, cat: Other, desc: " |- Зарубежные Мультсериалы"} - - {id: 734, cat: Other, desc: " |- Классика для мам и малышей"} - - {id: 742, cat: Other, desc: " |- Музыка и песни для детей"} - - {id: 735, cat: Other, desc: " |- Аудиокниги для детей и родителей"} - - {id: 738, cat: Other, desc: " |- Образование, обучение и развитие детей"} - - {id: 967, cat: Other, desc: " |- Логопедия, Психология, Педиатрия"} - - {id: 907, cat: Other, desc: " |- Журналы для детей и родителей"} - - {id: 739, cat: Other, desc: " |- Детская литература"} - - {id: 1109, cat: Other, desc: " |- Энциклопедии и Познавательная литература для детей"} - - {id: 736, cat: Other, desc: " |- Мультимедийные материалы для родителей"} - - {id: 737, cat: Other, desc: " |- Мультимедийные материалы для детей"} - - {id: 898, cat: Other, desc: " |- Рабочие тетради, прописи и разукрашки"} - - {id: 935, cat: Other, desc: " |- Настольные игры и Поделки"} - - {id: 871, cat: Other, desc: " |- Подвижные Игры"} - - {id: 973, cat: Other, desc: " |- Изобразительное искусство"} - - {id: 960, cat: Other, desc: " |- Прочие материалы для детей и родителей"} - - {id: 1239, cat: Other, desc: " |- ГИА, ОГЭ, ЕГЭ, ЕМЭ, ГВЭ"} - - {id: 740, cat: Other, desc: " |- Развивающие Игры для детей"} - - {id: 741, cat: Other, desc: " |- Детские Игры"} - # programs - - {id: 503, cat: Other, desc: "ОС Windows"} - - {id: 504, cat: Other, desc: " |- Оригинальные версии Windows"} - - {id: 506, cat: Other, desc: " |- Оригинальные версии Windows Server"} - - {id: 763, cat: Other, desc: " |- Windows OEM Recovery СD/DVD"} - - {id: 1335, cat: Other, desc: " |- Сборки Windows 11"} - - {id: 1241, cat: Other, desc: " |- Сборки Windows 10"} - - {id: 1023, cat: Other, desc: " |- Сборки Windows 8"} - - {id: 717, cat: Other, desc: " |- Сборки Windows 7"} - - {id: 509, cat: Other, desc: " |- Сборки Windows Vista"} - - {id: 508, cat: Other, desc: " |- Сборки Windows ХР"} - - {id: 510, cat: Other, desc: " |- Сборки Windows - всё в одном"} - - {id: 1254, cat: Other, desc: " |- Сборки Windows для незрячих"} - - {id: 1042, cat: Other, desc: " |- Песочница ПО и сборок Windows"} - - {id: 511, cat: Other, desc: " |- Разное (RC, Beta и Service Packs)"} - - {id: 916, cat: Other, desc: " |- Музей Windows"} - - {id: 512, cat: Other, desc: "Утилиты, Офис, Интернет"} - - {id: 561, cat: Other, desc: " |- ПО для Интернета и сетей"} - - {id: 1284, cat: Other, desc: " |- Оригинальные версии Office"} - - {id: 562, cat: Other, desc: " |- Офисное ПО"} - - {id: 513, cat: Other, desc: " |- Запись, создание, редактирование, эмуляция дисков и..."} - - {id: 514, cat: Other, desc: " |- Диагностика и обслуживание hardware"} - - {id: 515, cat: Other, desc: " |- Резервирование и восстановление данных"} - - {id: 516, cat: Other, desc: " |- Файловые менеджеры и архиваторы"} - - {id: 517, cat: Other, desc: " |- Обслуживание ОС"} - - {id: 518, cat: Other, desc: " |- Разное (Утилиты, Офис, Интернет)"} - - {id: 519, cat: Other, desc: "Безопасность"} - - {id: 520, cat: Other, desc: " |- Firewalls"} - - {id: 521, cat: Other, desc: " |- Антивирусы"} - - {id: 522, cat: Other, desc: " |- Комплексные системы защиты"} - - {id: 523, cat: Other, desc: " |- Разное (остальные программы по безопасности)"} - - {id: 524, cat: Other, desc: "Мультимедиа и Графика"} - - {id: 532, cat: Other, desc: " |- Аудио Плееры и Кодеки"} - - {id: 533, cat: Other, desc: " |- Аудио Граббинг, Мастеринг, Обработка"} - - {id: 535, cat: Other, desc: " |- Прочее ПО для Аудио"} - - {id: 530, cat: Other, desc: " |- Видео Плееры и Кодеки"} - - {id: 529, cat: Other, desc: " |- Нелинейный Видеомонтаж, Авторинг, Кодировщики"} - - {id: 525, cat: Other, desc: " |- Просмотрщики Графики (вьюверы)"} - - {id: 526, cat: Other, desc: " |- Графические редакторы"} - - {id: 527, cat: Other, desc: " |- ПО для моделирования"} - - {id: 545, cat: Other, desc: "Софт и оболочки для специалистов, Прочее"} - - {id: 764, cat: Other, desc: " |- LiveCD/DVD/Flash"} - - {id: 765, cat: Other, desc: " |- WPI"} - - {id: 820, cat: Other, desc: " |- Серверное ПО"} - - {id: 552, cat: Other, desc: " |- Разработка ПО"} - - {id: 553, cat: Other, desc: " |- САПР/ГИС"} - - {id: 554, cat: Other, desc: " |- Остальное ПО для специалистов"} - - {id: 550, cat: Other, desc: " |- Системы навигации и карты"} - - {id: 549, cat: Other, desc: " |- Драйверы"} - - {id: 548, cat: Other, desc: " |- Разное (прочее ПО)"} - # Movies - - {id: 216, cat: Movies, desc: "Горячие новинки"} - - {id: 270, cat: Movies, desc: " |- Отечественные Новинки (SD, DVD)"} - - {id: 218, cat: Movies, desc: " |- Зарубежные Новинки (SD, DVD)"} - - {id: 219, cat: Movies, desc: " |- Отечественные Новинки (HD, FHD, UHD, 3D)"} - - {id: 954, cat: Movies, desc: " |- Зарубежные Новинки (HD, FHD, UHD, 3D)"} - - {id: 217, cat: Movies, desc: " |- Экранки"} - - {id: 1293, cat: Movies, desc: " |- Новинки с Рекламой"} - - {id: 1298, cat: Movies, desc: " |- Экранки с рекламой"} - - {id: 318, cat: Movies, desc: "Классика кино и Старые фильмы до 90-х"} - - {id: 320, cat: Movies, desc: " |- Отечественная Классика (SD)"} - - {id: 677, cat: Movies, desc: " |- Отечественная Классика (DVD)"} - - {id: 1177, cat: Movies, desc: " |- Отечественная Классика (HD, FHD, UHD)"} - - {id: 319, cat: Movies, desc: " |- Зарубежная Классика (SD)"} - - {id: 678, cat: Movies, desc: " |- Зарубежная Классика (DVD)"} - - {id: 885, cat: Movies, desc: " |- Зарубежная Классика (HD, FHD, UHD, 3D)"} - - {id: 908, cat: Movies, desc: " |- Старые Отечественные Фильмы (SD)"} - - {id: 1310, cat: Movies, desc: " |- Старые Отечественные Фильмы (DVD)"} - - {id: 909, cat: Movies, desc: " |- Старые Отечественные Фильмы (HD, FHD, UHD)"} - - {id: 910, cat: Movies, desc: " |- Старые Зарубежные Фильмы (SD)"} - - {id: 911, cat: Movies, desc: " |- Старые Зарубежные Фильмы (DVD)"} - - {id: 912, cat: Movies, desc: " |- Старые Зарубежные Фильмы (HD, FHD, UHD, 3D)"} - - {id: 220, cat: Movies, desc: "Отечественное кино"} - - {id: 221, cat: Movies, desc: " |- Отечественные Фильмы (SD)"} - - {id: 222, cat: Movies, desc: " |- Отечественные Фильмы (DVD)"} - - {id: 882, cat: Movies, desc: " |- Отечественные Фильмы (HD, FHD, UHD)"} - - {id: 889, cat: Movies, desc: " |- Отечественные Фильмы (3D)"} - - {id: 224, cat: Movies, desc: "Зарубежное кино"} - - {id: 225, cat: Movies, desc: " |- Зарубежные Фильмы (SD)"} - - {id: 226, cat: Movies, desc: " |- Зарубежные Фильмы (DVD)"} - - {id: 227, cat: Movies, desc: " |- Зарубежные Фильмы (HD, FHD)"} - - {id: 1296, cat: Movies, desc: " |- Зарубежные Фильмы (UHD)"} - - {id: 891, cat: Movies, desc: " |- Зарубежные Фильмы (3D)"} - - {id: 1299, cat: Movies, desc: " |- Фильмы ближнего зарубежья"} - - {id: 682, cat: Movies, desc: " |- Азиатское кино (SD)"} - - {id: 694, cat: Movies, desc: " |- Азиатское кино (DVD)"} - - {id: 884, cat: Movies, desc: " |- Азиатское кино (HD, FHD, UHD)"} - - {id: 1211, cat: Movies, desc: " |- Азиатское кино (3D)"} - - {id: 693, cat: Movies, desc: " |- Индийское кино"} - - {id: 913, cat: Movies, desc: " |- Фильмы с переводом на др. языках"} - - {id: 228, cat: Movies, desc: " |- Фильмы в оригинале (SD, DVD)"} - - {id: 1150, cat: Movies, desc: " |- Фильмы в оригинале (HD, FHD, UHD)"} - - {id: 1311, cat: Movies, desc: "Коллекции / *логии"} - - {id: 1313, cat: Movies, desc: " |- Зарубежное кино (коллекции / *логии)"} - - {id: 1312, cat: Movies, desc: " |- Отечественное кино (коллекции / *логии)"} - - {id: 256, cat: Movies, desc: "Театр и Музыкальное видео"} - - {id: 257, cat: Movies, desc: " |- Музыкальные клипы"} - - {id: 258, cat: Movies, desc: " |- Концерты (SD)"} - - {id: 883, cat: Movies, desc: " |- Концерты (DVD)"} - - {id: 955, cat: Movies, desc: " |- Концерты (HD, FHD, UHD, 3D)"} - - {id: 905, cat: Movies, desc: " |- Театр"} - - {id: 271, cat: Movies, desc: " |- Опера, Балет, Мюзиклы"} - - {id: 1210, cat: Movies, desc: " |- Караоке"} - - {id: 264, cat: Movies, desc: "Остальное"} - - {id: 265, cat: Movies, desc: " |- Звуковые дорожки и субтитры"} - - {id: 272, cat: Movies, desc: " |- Игровое видео"} - - {id: 1262, cat: Movies, desc: " |- hand made * video"} - - {id: 266, cat: Movies, desc: " |- Трейлеры"} - - {id: 1294, cat: Movies, desc: " |- Фильмы с Рекламой"} - # TV - - {id: 1219, cat: TV, desc: "Классика сериалов и многосерийное кино до 90-х"} - - {id: 1221, cat: TV, desc: " |- Отечественная классика сериалов и старое многосерийное..."} - - {id: 1220, cat: TV, desc: " |- Зарубежная классика сериалов и старое многосерийное кино..."} - - {id: 722, cat: TV, desc: " |- Чертова служба в госпитале МЭШ / M*A*S*H"} - - {id: 768, cat: TV, desc: "Зарубежные сериалы"} - - {id: 1344, cat: Other, desc: " |- Звездные войны / Star Wars (сериалы по франшизе)"} - - {id: 779, cat: TV, desc: " |- Анатомия страсти / Grey's Anatomy"} - - {id: 1288, cat: TV, desc: " |- Во все тяжкие / Breaking Bad; Лучше звоните Солу / Better..."} - - {id: 787, cat: TV, desc: " |- Грань / Fringe"} - - {id: 1141, cat: TV, desc: " |- Дневники вампира / Vampire Diaries; Настоящая кровь /..."} - - {id: 777, cat: TV, desc: " |- Доктор кто / Doctor Who; Торчвуд / Torchwood"} - - {id: 786, cat: TV, desc: " |- Доктор Хаус / House M.D."} - - {id: 776, cat: TV, desc: " |- Звездные врата / Stargate"} - - {id: 785, cat: TV, desc: " |- Звездный Крейсер Галактика / BattleStar Galactica;..."} - - {id: 775, cat: TV, desc: " |- Звездный путь / Star Trek; Орвилл / The Orville"} - - {id: 1265, cat: TV, desc: " |- Игра престолов / Game of Thrones"} - - {id: 1242, cat: TV, desc: " |- Касл / Castle"} - - {id: 1140, cat: TV, desc: " |- Кости / Bones"} - - {id: 782, cat: TV, desc: " |- Менталист / The Mentalist; Теория Лжи / Lie To Me"} - - {id: 773, cat: TV, desc: " |- Место преступления / CSI"} - - {id: 1142, cat: TV, desc: " |- Морская полиция / Navy NCIS; Военно-юридическая служба /..."} - - {id: 772, cat: TV, desc: " |- Побег / Prison Break"} - - {id: 771, cat: TV, desc: " |- Пуаро / Poirot"} - - {id: 783, cat: TV, desc: " |- Сверхъестественное / Supernatural"} - - {id: 1144, cat: TV, desc: " |- Секретные материалы / X-Files"} - - {id: 804, cat: TV, desc: " |- Теория Большого Взрыва / The Big Bang Theory; Детство..."} - - {id: 1290, cat: TV, desc: " |- Ходячие мертвецы / The Walking Dead; Бойтесь ходячих..."} - - {id: 1300, cat: TV, desc: " |- Сериалы ближнего зарубежья"} - - {id: 784, cat: TV, desc: " |- Сериалы DC Comics"} - - {id: 774, cat: TV, desc: " |- Сериалы Marvel Comics"} - - {id: 922, cat: TV, desc: " |- Азиатские сериалы"} - - {id: 770, cat: TV, desc: " |- Латиноамериканские сериалы"} - - {id: 1320, cat: TV, desc: " |- Турецкие сериалы"} - - {id: 780, cat: TV, desc: " |- Сериалы без русского перевода (украинская озвучка)"} - - {id: 781, cat: TV, desc: " |- Сериалы без перевода"} - - {id: 1322, cat: TV, desc: " |- Сериалы с рекламой"} - - {id: 769, cat: TV, desc: "Отечественные сериалы"} - - {id: 799, cat: TV, desc: " |- Бандитский Петербург"} - - {id: 800, cat: TV, desc: " |- Глухарь"} - - {id: 791, cat: TV, desc: " |- Интерны"} - - {id: 793, cat: TV, desc: " |- Ментовские войны"} - - {id: 794, cat: TV, desc: " |- Менты"} - - {id: 796, cat: TV, desc: " |- Солдаты"} - - {id: 795, cat: TV, desc: " |- Универ"} - # Documentary, Telecasts, Sports, Comedy - - {id: 713, cat: TV, desc: "Зарубежные TV-бренды"} - - {id: 706, cat: TV, desc: " |- Animal Planet"} - - {id: 577, cat: TV, desc: " |- BBC"} - - {id: 894, cat: TV, desc: " |- Da Vinci Learning"} - - {id: 578, cat: TV, desc: " |- Discovery"} - - {id: 580, cat: TV, desc: " |- History Channel"} - - {id: 579, cat: TV, desc: " |- National Geographic"} - - {id: 953, cat: TV, desc: " |- PBS"} - - {id: 581, cat: TV, desc: " |- Readers Digest"} - - {id: 806, cat: TV, desc: " |- Интересно обо всем"} - - {id: 714, cat: TV, desc: " |- Мега-Проекты"} - - {id: 761, cat: TV, desc: " |- Доисторический мир"} - - {id: 809, cat: TV, desc: " |- Мир будущего"} - - {id: 924, cat: TV, desc: " |- Одиссея Жака Кусто"} - - {id: 812, cat: TV, desc: " |- Тайны и Загадки"} - - {id: 576, cat: TV, desc: "Документалистика и Телепередачи"} - - {id: 590, cat: TV, desc: " |- Кинолетопись Страны Советской"} - - {id: 591, cat: TV, desc: " |- Вторая Мировая война"} - - {id: 588, cat: TV, desc: " |- Отечественная история"} - - {id: 589, cat: TV, desc: " |- История"} - - {id: 598, cat: TV, desc: " |- Личности в истории"} - - {id: 652, cat: TV, desc: " |- Посвящение искусству"} - - {id: 599, cat: TV, desc: " |- Телешоу"} - - {id: 959, cat: TV, desc: " |- Музыкальные шоу"} - - {id: 956, cat: TV, desc: " |- Интеллектуальные шоу"} - - {id: 597, cat: TV, desc: " |- Непознанное и сверхъестественное"} - - {id: 593, cat: TV, desc: " |- Живая природа"} - - {id: 594, cat: TV, desc: " |- Клуб кинопутешествий"} - - {id: 819, cat: TV, desc: " |- За рулем"} - - {id: 595, cat: TV, desc: " |- Релакс / Relax"} - - {id: 587, cat: TV, desc: " |- Военное дело"} - - {id: 584, cat: TV, desc: " |- Авиация"} - - {id: 586, cat: TV, desc: " |- Космос"} - - {id: 585, cat: TV, desc: " |- Флот"} - - {id: 600, cat: TV, desc: " |- Тележурналистика"} - - {id: 596, cat: TV, desc: " |- Политика и пропаганда, публицистика и экономика"} - - {id: 1295, cat: TV, desc: " |- Общественно-политические и пропагандистские ток-шоу"} - - {id: 614, cat: TV, desc: " |- Религии и культы (Док/TV)"} - - {id: 603, cat: TV, desc: "Спорт и активный отдых"} - - {id: 1308, cat: TV, desc: " |- Хоккей. Чемпионат мира"} - - {id: 1309, cat: TV, desc: " |- Футбол. Чемпионат Европы"} - - {id: 1206, cat: TV, desc: " |- Футбол. Чемпионат Мира"} - - {id: 1194, cat: TV, desc: " |- Летние Олимпийские игры и Паралимпийские игры"} - - {id: 1062, cat: TV, desc: " |- Зимние Олимпийские игры и Паралимпийские игры"} - - {id: 974, cat: TV, desc: " |- Футбол"} - - {id: 609, cat: TV, desc: " |- Баскетбол, Волейбол, Гандбол"} - - {id: 1263, cat: TV, desc: " |- Хоккей"} - - {id: 951, cat: TV, desc: " |- Прочие зимние виды спорта"} - - {id: 975, cat: TV, desc: " |- Бокс"} - - {id: 608, cat: TV, desc: " |- Единоборства, Бои без правил"} - - {id: 607, cat: TV, desc: " |- Гимнастика, Бодибилдинг, Красота тела"} - - {id: 606, cat: TV, desc: " |- Авто, Мото"} - - {id: 750, cat: TV, desc: " |- Формула 1"} - - {id: 605, cat: TV, desc: " |- Экстрим"} - - {id: 604, cat: TV, desc: " |- Рыбалка и Охота"} - - {id: 950, cat: TV, desc: " |- Бильярд"} - - {id: 610, cat: TV, desc: "Юмор (ТВ)"} - - {id: 613, cat: TV, desc: " |- КВН"} - - {id: 612, cat: TV, desc: " |- ПостКВН"} - - {id: 653, cat: TV, desc: " |- Украинские шоу"} - - {id: 654, cat: TV, desc: " |- Маски-шоу"} - - {id: 611, cat: TV, desc: " |- Сатирики"} - - {id: 656, cat: TV, desc: " |- Приколы"} - # anime - - {id: 615, cat: TV/Anime, desc: "Манга и Арт"} - - {id: 616, cat: TV/Anime, desc: " |- Манга, Манхва, Маньхуа"} - - {id: 617, cat: TV/Anime, desc: " |- Ранобэ"} - - {id: 648, cat: TV/Anime, desc: " |- Визуальные новеллы"} - - {id: 619, cat: TV/Anime, desc: " |- Аниме арт"} - - {id: 620, cat: TV/Anime, desc: "Аниме с субтитрами"} - - {id: 623, cat: TV/Anime, desc: " |- Онгоинги"} - - {id: 622, cat: TV/Anime, desc: " |- Аниме (SD)"} - - {id: 621, cat: TV/Anime, desc: " |- Аниме (HD)"} - - {id: 632, cat: TV/Anime, desc: " |- Аниме (FullHD)"} - - {id: 624, cat: TV/Anime, desc: "Аниме с озвучкой"} - - {id: 627, cat: TV/Anime, desc: " |- Онгоинги с озвучкой"} - - {id: 626, cat: TV/Anime, desc: " |- Аниме с озвучкой (SD)"} - - {id: 625, cat: TV/Anime, desc: " |- Аниме с озвучкой (HD)"} - - {id: 644, cat: TV/Anime, desc: " |- Аниме с озвучкой (FullHD)"} - - {id: 628, cat: TV/Anime, desc: "Аниме разное"} - - {id: 635, cat: TV/Anime, desc: " |- Аниме DVD"} - - {id: 634, cat: TV/Anime, desc: " |- Аниме Blu-ray, Remux"} - - {id: 638, cat: TV/Anime, desc: " |- Аниме хардсаб"} - - {id: 646, cat: TV/Anime, desc: " |- Аниме прочее"} - - {id: 645, cat: TV/Anime, desc: "Аниме музыка"} - - {id: 639, cat: TV/Anime, desc: " |- Аниме OST (Lossless)"} - - {id: 640, cat: TV/Anime, desc: " |- Аниме OST"} - # Books and Training Materials - - {id: 432, cat: Books, desc: "Научная и техническая литература"} - - {id: 755, cat: Books, desc: " |- Учебники"} - - {id: 481, cat: Books, desc: " |- Иностранные языки (литература)"} - - {id: 557, cat: Books, desc: " |- Гуманитарные науки и искусство"} - - {id: 442, cat: Books, desc: " |- Точные и естественные науки"} - - {id: 441, cat: Books, desc: " |- Техническая литература"} - - {id: 875, cat: Books, desc: " |- Военно-историческая литература"} - - {id: 1176, cat: Books, desc: " |- Историческая литература"} - - {id: 444, cat: Books, desc: " |- Научно-популярная литература"} - - {id: 443, cat: Books, desc: " |- Здоровье и медицина"} - - {id: 440, cat: Books, desc: " |- Нормативная документация"} - - {id: 1199, cat: Books, desc: " |- Энциклопедии и словари"} - - {id: 433, cat: Books, desc: "Компьютерная литература"} - - {id: 447, cat: Books, desc: " |- Программирование"} - - {id: 445, cat: Books, desc: " |- Веб-дизайн"} - - {id: 817, cat: Books, desc: " |- 2D графика"} - - {id: 818, cat: Books, desc: " |- 3D графика"} - - {id: 434, cat: Books, desc: "Художественная литература"} - - {id: 1349, cat: Books, desc: " |- Библиотеки"} - - {id: 957, cat: Books, desc: " |- Многоавторские сборники и Библиотеки"} - - {id: 931, cat: Books, desc: " |- Собрания книг русскоязычных авторов"} - - {id: 1152, cat: Books, desc: " |- Собрания книг иностранных авторов"} - - {id: 455, cat: Books, desc: " |- Сатира, Юмор"} - - {id: 453, cat: Books, desc: " |- Боевики, Детективы, Триллеры"} - - {id: 1063, cat: Books, desc: " |- Приключенческая проза "} - - {id: 452, cat: Books, desc: " |- Историческая проза, Мифы и Легенды"} - - {id: 451, cat: Books, desc: " |- Фантастика, Фэнтези"} - - {id: 449, cat: Books, desc: " |- Современная поэзия и проза"} - - {id: 1153, cat: Books, desc: " |- Классическая поэзия и проза"} - - {id: 1347, cat: Books, desc: "Книги вне издательств, самиздат (все жанры)"} - - {id: 482, cat: Books, desc: "Комиксы и Артбуки"} - - {id: 483, cat: Books, desc: " |- Комиксы на русском языке"} - - {id: 484, cat: Books, desc: " |- Комиксы без перевода"} - - {id: 1343, cat: Books, desc: " |- Артбуки"} - - {id: 438, cat: Books, desc: "Художественные аудиокниги и публицистика"} - - {id: 485, cat: Books, desc: " |- Сатира, Юмор (аудиокниги)"} - - {id: 473, cat: Books, desc: " |- Детектив, Боевик (аудиокниги)"} - - {id: 472, cat: Books, desc: " |- Исторические аудиокниги"} - - {id: 471, cat: Books, desc: " |- Классика (аудиокниги)"} - - {id: 895, cat: Books, desc: " |- Проза, Поэзия (аудиокниги)"} - - {id: 470, cat: Books, desc: " |- Фантастика, Фэнтези (аудиокниги)"} - - {id: 896, cat: Books, desc: " |- Публицистика (аудиокниги)"} - - {id: 480, cat: Books, desc: " |- Другие аудиокниги"} - - {id: 436, cat: Audio/Audiobook, desc: "Обучающие аудиоматериалы"} - - {id: 458, cat: Audio/Audiobook, desc: " |- Бизнес и Менеджмент (аудиоматериалы)"} - - {id: 457, cat: Audio/Audiobook, desc: " |- Иностранные языки (аудиоматериалы)"} - - {id: 1342, cat: Audio/Audiobook, desc: " |- Здоровье и Медицина (аудиоматериалы)"} - - {id: 459, cat: Audio/Audiobook, desc: " |- Популярная психология и саморазвитие (аудиоматериалы)"} - - {id: 460, cat: Audio/Audiobook, desc: " |- Медитации (аудиоматериалы)"} - - {id: 461, cat: Audio/Audiobook, desc: " |- Религия (аудиоматериалы)"} - - {id: 462, cat: Audio/Audiobook, desc: " |- Прочие аудиоматериалы"} - - {id: 437, cat: Books, desc: "Обучающие видеоматериалы"} - - {id: 466, cat: TV, desc: " |- Бизнес и Менеджмент (видеокурсы)"} - - {id: 1319, cat: TV, desc: " |- Иностранные языки (видеокурсы)"} - - {id: 463, cat: TV, desc: " |- IT, Компьютерные видеокурсы"} - - {id: 958, cat: TV, desc: " |- Дизайн, рисование (видеокурсы)"} - - {id: 1223, cat: TV, desc: " |- Фотография и Видео (видеокурсы)"} - - {id: 467, cat: TV, desc: " |- Здоровье и Спорт (видеокурсы)"} - - {id: 464, cat: TV, desc: " |- Психология и саморазвитие (видеокурсы)"} - - {id: 465, cat: TV, desc: " |- Музыка (видеокурсы)"} - - {id: 1348, cat: TV, desc: " |- Домоводство, строительство и ремонт (видеокурсы)"} - - {id: 469, cat: TV, desc: " |- Другие видеокурсы"} - - {id: 439, cat: Books, desc: "Мультимедийные материалы"} - - {id: 477, cat: Books, desc: " |- Образование"} - - {id: 476, cat: Books, desc: " |- Иностранные языки"} - - {id: 475, cat: Books, desc: " |- Компьютеры"} - - {id: 474, cat: Books, desc: " |- Мультимедийные справочники, Энциклопедии"} - - {id: 886, cat: Books, desc: " |- Приложения к журналам"} - - {id: 478, cat: Books, desc: " |- Другие мультимедийные материалы"} - - {id: 486, cat: Books, desc: "Журналы"} - - {id: 490, cat: Books, desc: " |- Мужские журналы"} - - {id: 657, cat: Books, desc: " |- Женские журналы"} - - {id: 489, cat: Books, desc: " |- Игровые журналы"} - - {id: 488, cat: Books, desc: " |- Компьютерные журналы"} - - {id: 487, cat: Books, desc: " |- Научно-популярные журналы"} - - {id: 1198, cat: Books, desc: " |- Журналы по электротехнике и радиоэлектронике"} - - {id: 1227, cat: Books, desc: " |- Кулинарные журналы"} - - {id: 893, cat: Books, desc: " |- Домоводство (журналы)"} - - {id: 491, cat: Books, desc: " |- Хобби (журналы)"} - - {id: 767, cat: Books, desc: " |- Другие журналы"} - - {id: 299, cat: Books, desc: "Автомобили"} - - {id: 887, cat: Books, desc: " |- Автомобильные журналы"} - - {id: 301, cat: Books, desc: " |- Автомобильная литература"} - - {id: 1334, cat: Books, desc: " |- Автомобильные обуч.видео"} - - {id: 300, cat: Books, desc: " |- Автомобильные мультимедийные материалы"} - - {id: 1341, cat: Books, desc: " |- Автомобильные программы и навигаторы"} - - {id: 492, cat: Books, desc: "Разное (категория книг)"} - - {id: 558, cat: Books, desc: " |- Бизнес, Менеджмент, Деловая литература"} - - {id: 1173, cat: Books, desc: " |- Публицистика"} - - {id: 1174, cat: Books, desc: " |- Популярная психология и саморазвитие"} - - {id: 1171, cat: Books, desc: " |- Эзотерика"} - - {id: 662, cat: Books, desc: " |- Религиозная литература"} - - {id: 1175, cat: Books, desc: " |- Нетрадиционная медицина"} - - {id: 1172, cat: Books, desc: " |- Строительство и ремонт"} - - {id: 933, cat: Books, desc: " |- Сад, огород, животноводство"} - - {id: 815, cat: Books, desc: " |- Кулинария"} - - {id: 1170, cat: Books, desc: " |- Спорт, Фитнес, Боевые искусства"} - - {id: 398, cat: Books, desc: " |- Ноты и обучение музыке"} - - {id: 816, cat: Books, desc: " |- На досуге"} - # Music - - {id: 313, cat: Audio, desc: "HD Audio и Многоканальная Музыка"} - - {id: 1291, cat: Audio, desc: " |- Blu-ray Audio"} - - {id: 680, cat: Audio, desc: " |- DVD-Audio"} - - {id: 1149, cat: Audio, desc: " |- SACD-R"} - - {id: 429, cat: Audio, desc: " |- DTS-Audio"} - - {id: 681, cat: Audio, desc: " |- Vinyl-Rip и Hand-Made"} - - {id: 330, cat: Audio, desc: "Классика"} - - {id: 1256, cat: Audio, desc: " |- Классика (HD Audio)"} - - {id: 1285, cat: Audio/Lossless, desc: " |- Полные собрания сочинений (Lossless)"} - - {id: 370, cat: Audio, desc: " |- Полные собрания сочинений"} - - {id: 1260, cat: Audio/Lossless, desc: " |- Вокал (Lossless)"} - - {id: 371, cat: Audio, desc: " |- Вокал"} - - {id: 1261, cat: Audio/Lossless, desc: " |- Концерты (Lossless)"} - - {id: 375, cat: Audio, desc: " |- Концерты"} - - {id: 1259, cat: Audio/Lossless, desc: " |- Оркестровая (Lossless)"} - - {id: 374, cat: Audio, desc: " |- Оркестровая"} - - {id: 1257, cat: Audio/Lossless, desc: " |- Камерная (Lossless)"} - - {id: 373, cat: Audio, desc: " |- Камерная"} - - {id: 1258, cat: Audio/Lossless, desc: " |- Фортепиано (Lossless)"} - - {id: 372, cat: Audio, desc: " |- Фортепиано"} - - {id: 1160, cat: Audio/Lossless, desc: " |- В обработке/Classical Crossover/Neoclassical (Lossless)"} - - {id: 876, cat: Audio, desc: " |- В обработке/Classical Crossover/Neoclassical"} - - {id: 1255, cat: Audio/Lossless, desc: " |- Классика (сборники) (Lossless)"} - - {id: 376, cat: Audio, desc: " |- Классика (сборники)"} - - {id: 326, cat: Audio, desc: "Jazz, Blues, Soul"} - - {id: 1352, cat: Audio, desc: " |- Jazz (Hi-Res)"} - - {id: 359, cat: Audio/Lossless, desc: " |- Jazz (Lossless)"} - - {id: 358, cat: Audio, desc: " |- Jazz"} - - {id: 1353, cat: Audio, desc: " |- Blues, Soul (Hi-Res)"} - - {id: 1188, cat: Audio/Lossless, desc: " |- Blues, Soul (Lossless)"} - - {id: 1189, cat: Audio, desc: " |- Blues, Soul"} - - {id: 328, cat: Audio, desc: "Шансон, Авторская и Военная песня"} - - {id: 1370, cat: Audio, desc: " |- Шансон, Авторская и Военная песня (Hi-Res)"} - - {id: 1180, cat: Audio/Lossless, desc: " |- Зарубежный Шансон (Lossless)"} - - {id: 1181, cat: Audio, desc: " |- Зарубежный Шансон"} - - {id: 364, cat: Audio/Lossless, desc: " |- Русский Шансон (Lossless)"} - - {id: 363, cat: Audio, desc: " |- Русский Шансон"} - - {id: 1179, cat: Audio/Lossless, desc: " |- Авторская и Военная песня (Lossless)"} - - {id: 879, cat: Audio, desc: " |- Авторская и Военная песня"} - - {id: 322, cat: Audio, desc: "Rock, Alternative, Punk, Metal"} - - {id: 1350, cat: Audio, desc: " |- Rock (Hi-Res)"} - - {id: 962, cat: Audio/Lossless, desc: " |- Rock (Lossless)"} - - {id: 333, cat: Audio, desc: " |- Rock"} - - {id: 1356, cat: Audio, desc: " |- Alternative, Punk (Hi-Res)"} - - {id: 965, cat: Audio/Lossless, desc: " |- Alternative, Punk (Lossless)"} - - {id: 336, cat: Audio, desc: " |- Alternative, Punk"} - - {id: 1362, cat: Audio, desc: " |- Hard Rock (Hi Res)"} - - {id: 337, cat: Audio/Lossless, desc: " |- Hard Rock (Lossless)"} - - {id: 338, cat: Audio, desc: " |- Hard Rock"} - - {id: 1351, cat: Audio, desc: " |- Metal (Hi-Res)"} - - {id: 963, cat: Audio/Lossless, desc: " |- Metal (Lossless)"} - - {id: 334, cat: Audio, desc: " |- Metal"} - - {id: 1357, cat: Audio, desc: " |- Русский Рок (Hi-Res)"} - - {id: 961, cat: Audio/Lossless, desc: " |- Русский Рок (Lossless)"} - - {id: 332, cat: Audio, desc: " |- Русский рок"} - - {id: 325, cat: Audio, desc: "Pop"} - - {id: 1354, cat: Audio, desc: " |- Pop (Hi-Res)"} - - {id: 1165, cat: Audio/Lossless, desc: " |- Eurodance, Euro-House, Technopop (Lossless)"} - - {id: 1166, cat: Audio, desc: " |- Eurodance, Euro-House, Technopop"} - - {id: 1168, cat: Audio, desc: " |- Disco, Italo-Disco, Euro-Disco, Hi-NRG"} - - {id: 1167, cat: Audio/Lossless, desc: " |- Disco, Italo-Disco, Euro-Disco, Hi-NRG (Lossless)"} - - {id: 1162, cat: Audio/Lossless, desc: " |- Отечественная поп-музыка (Lossless)"} - - {id: 352, cat: Audio, desc: " |- Отечественная поп-музыка"} - - {id: 1164, cat: Audio/Lossless, desc: " |- Советская эстрада, Ретро (Lossless)"} - - {id: 1163, cat: Audio, desc: " |- Советская эстрада, Ретро"} - - {id: 1161, cat: Audio/Lossless, desc: " |- Зарубежная поп-музыка (Lossless)"} - - {id: 353, cat: Audio, desc: " |- Зарубежная поп-музыка"} - - {id: 324, cat: Audio, desc: "Electronic"} - - {id: 1327, cat: Audio/Lossless, desc: " |- Psybient, Psychill, Psydub (Lossless)"} - - {id: 1328, cat: Audio, desc: " |- Psybient, Psychill, Psydub"} - - {id: 1325, cat: Audio/Lossless, desc: " |- Downtempo, Trip-Hop, Lounge (Lossless)"} - - {id: 1326, cat: Audio, desc: " |- Downtempo, Trip-Hop, Lounge"} - - {id: 1365, cat: Audio, desc: " |- Downtempo, Ambient (Hi-Res)"} - - {id: 1366, cat: Audio, desc: " |- Experimental, Industrial (Hi-Res)"} - - {id: 1323, cat: Audio/Lossless, desc: " |- Ambient, Experimental, Modern Classical (Lossless)"} - - {id: 1324, cat: Audio, desc: " |- Ambient, Experimental, Modern Classical"} - - {id: 976, cat: Audio/Lossless, desc: " |- Trance (Lossless)"} - - {id: 346, cat: Audio, desc: " |- Trance"} - - {id: 1363, cat: Audio, desc: " |- Trance, House, Techno (Hi-Res)"} - - {id: 977, cat: Audio/Lossless, desc: " |- House, Techno, Electro, Minimal (Lossless)"} - - {id: 345, cat: Audio, desc: " |- House"} - - {id: 349, cat: Audio, desc: " |- Techno, Electro, Minimal"} - - {id: 1243, cat: Audio, desc: " |- Label-Packs"} - - {id: 347, cat: Audio, desc: " |- Easy listening"} - - {id: 979, cat: Audio/Lossless, desc: " |- Industrial, EBM, Dark Electro (Lossless)"} - - {id: 673, cat: Audio, desc: " |- Experimental Electronic"} - - {id: 671, cat: Audio, desc: " |- Industrial, EBM, Dark Electro"} - - {id: 1224, cat: Audio/Lossless, desc: " |- IDM (Lossless)"} - - {id: 1225, cat: Audio, desc: " |- IDM"} - - {id: 1367, cat: Audio, desc: " |- Synthpop, New Wave, Retro (Hi-Res)"} - - {id: 980, cat: Audio/Lossless, desc: " |- Synthpop, New Wave (Lossless)"} - - {id: 672, cat: Audio, desc: " |- Synthpop, New Wave"} - - {id: 1316, cat: Audio/Lossless, desc: " |- Dubstep, Future Garage, Bass Music, UK Garage (Lossless)"} - - {id: 1317, cat: Audio, desc: " |- Dubstep, Future Garage, Bass Music, UK Garage"} - - {id: 1364, cat: Audio, desc: " |- Drum'n'Bass, Breakbeat (Hi-Res)"} - - {id: 981, cat: Audio/Lossless, desc: " |- Drum'n'Bass, Jungle, Breaks, Breakbeat (Lossless)"} - - {id: 344, cat: Audio, desc: " |- Drum'n'Bass, Jungle, Breaks, Breakbeat"} - - {id: 1368, cat: Audio, desc: " |- Hardcore, Extreme (Hi-Res)"} - - {id: 983, cat: Audio/Lossless, desc: " |- Hardstyle, Jumpstyle, Hardcore (Lossless)"} - - {id: 984, cat: Audio, desc: " |- Hardstyle, Jumpstyle, Hardcore"} - - {id: 982, cat: Audio/Lossless, desc: " |- Psychedelic, psytrance, fullon (Lossless)"} - - {id: 348, cat: Audio, desc: " |- Psychedelic, psytrance, fullon"} - - {id: 674, cat: Audio, desc: " |- Radioshow, Live Mixes"} - - {id: 323, cat: Audio, desc: "Rap, Hip-hop, RnB, Reggae"} - - {id: 1187, cat: Audio/Lossless, desc: " |- Rap, Hip-hop зарубежный (Lossless)"} - - {id: 339, cat: Audio, desc: " |- Rap, Hip-hop зарубежный"} - - {id: 1186, cat: Audio/Lossless, desc: " |- Rap, Hip-hop отечественный (Lossless)"} - - {id: 340, cat: Audio, desc: " |- Rap, Hip-hop отечественный"} - - {id: 1185, cat: Audio/Lossless, desc: " |- RnB, Reggae (Lossless)"} - - {id: 341, cat: Audio, desc: " |- RnB, Reggae"} - - {id: 329, cat: Audio, desc: "East Asian Music"} - - {id: 1361, cat: Audio, desc: " |- Asian Music (Hi-Res)"} - - {id: 369, cat: Audio/Lossless, desc: " |- Asian Traditional, Ethnic (Lossless)"} - - {id: 368, cat: Audio, desc: " |- Asian Traditional, Ethnic"} - - {id: 1218, cat: Audio/Lossless, desc: " |- Asian Pop (Lossless)"} - - {id: 365, cat: Audio, desc: " |- Asian Pop"} - - {id: 1217, cat: Audio/Lossless, desc: " |- Asian Rock, Metal (Lossless)"} - - {id: 366, cat: Audio, desc: " |- Asian Rock, Metal"} - - {id: 1215, cat: Audio/Lossless, desc: " |- Doujin Music (Lossless)"} - - {id: 1216, cat: Audio, desc: " |- Doujin Music"} - - {id: 1213, cat: Audio/Lossless, desc: " |- Other Asian (Lossless)"} - - {id: 367, cat: Audio, desc: " |- Other Asian"} - - {id: 331, cat: Audio, desc: "Other Styles"} - - {id: 1358, cat: Audio, desc: " |- Instrumental (Hi-Res)"} - - {id: 1157, cat: Audio/Lossless, desc: " |- Instrumental (Lossless)"} - - {id: 711, cat: Audio, desc: " |- Instrumental"} - - {id: 1159, cat: Audio/Lossless, desc: " |- New Age/Meditative/Relax (Lossless)"} - - {id: 378, cat: Audio, desc: " |- New Age/Meditative/Relax"} - - {id: 1359, cat: Audio, desc: " |- Folk (Hi-Res)"} - - {id: 1158, cat: Audio/Lossless, desc: " |- Folk (Lossless)"} - - {id: 379, cat: Audio, desc: " |- Folk"} - - {id: 380, cat: Audio/Lossless, desc: " |- Other (Lossless)"} - - {id: 1178, cat: Audio, desc: " |- Other"} - - {id: 1360, cat: Audio, desc: " |- OST (Hi-Res)"} - - {id: 361, cat: Audio/Lossless, desc: " |- OST (Lossless)"} - - {id: 360, cat: Audio, desc: " |- OST"} - - {id: 327, cat: Audio, desc: "Неофициальные сборники"} - - {id: 1184, cat: Audio, desc: " |- Jazz, Blues, Soul (сборники)"} - - {id: 824, cat: Audio, desc: " |- Шансон, Авторская и Военная песня (сборники)"} - - {id: 1182, cat: Audio, desc: " |- Rock, Alternative, Punk, Metal (сборники)"} - - {id: 354, cat: Audio, desc: " |- Pop (сборники)"} - - {id: 877, cat: Audio, desc: " |- Electronic (сборники)"} - - {id: 1183, cat: Audio, desc: " |- Rap, Hip-hop, RnB, Reggae (сборники)"} - - {id: 1190, cat: Audio, desc: " |- Instrumental/New Age/Meditative/Relax (сборники)"} - - {id: 917, cat: Audio, desc: " |- Прочее (сборники)"} - # Other - - {id: 410, cat: Other, desc: "Win Игры"} - - {id: 411, cat: Other, desc: " |- Горячие новинки Игр"} - - {id: 412, cat: Other, desc: " |- Action (FPS)"} - - {id: 1008, cat: Other, desc: " |- Action (TPS)"} - - {id: 415, cat: Other, desc: " |- Adventure/Quest"} - - {id: 746, cat: Other, desc: " |- Arcade"} - - {id: 428, cat: Other, desc: " |- Online (MMO)"} - - {id: 1009, cat: Other, desc: " |- Online Action (MMO)"} - - {id: 413, cat: Other, desc: " |- RPG"} - - {id: 414, cat: Other, desc: " |- Strategy (RTS/TBS/Grand)"} - - {id: 1010, cat: Other, desc: " |- Strategy Tactical (RTS/TBS)"} - - {id: 1012, cat: Other, desc: " |- Strategy (Manage/Busin)"} - - {id: 1014, cat: Other, desc: " |- Racing"} - - {id: 416, cat: Other, desc: " |- Simulation (Flight/Space)"} - - {id: 1013, cat: Other, desc: " |- Simulation (Sport)"} - - {id: 1015, cat: Other, desc: " |- Simulation (Other)"} - - {id: 268, cat: Other, desc: " |- Action/Arcade/Platformer (Casual)"} - - {id: 1016, cat: Other, desc: " |- Adventure/Quest (Casual)"} - - {id: 1041, cat: Other, desc: " |- Classic Arcade/Zuma/3match (Casual)"} - - {id: 1018, cat: Other, desc: " |- Board/Puzzle/Logic (Casual)"} - - {id: 1017, cat: Other, desc: " |- Strategy/Manager/Business (Casual)"} - - {id: 972, cat: Other, desc: " |- AddOn/DLC/Mod для Игр"} - - {id: 971, cat: Other, desc: " |- Demo/Beta версии Игр"} - - {id: 970, cat: Other, desc: " |- Языковые пакеты для Игр"} - - {id: 969, cat: Other, desc: " |- Patch/Tweak/Trainer/Other для Игр"} - - {id: 968, cat: Other, desc: " |- NoCD/NoDVD/Crack для Игр"} - - {id: 1146, cat: Other, desc: " |- Песочница Win Игр"} - - {id: 418, cat: Other, desc: "Win Старые Игры"} - - {id: 1061, cat: Other, desc: " |- Action (FPS)"} - - {id: 1060, cat: Other, desc: " |- Action (TPS)"} - - {id: 1059, cat: Other, desc: " |- Adventure/Quest"} - - {id: 1058, cat: Other, desc: " |- Arcade"} - - {id: 1057, cat: Other, desc: " |- RPG"} - - {id: 1056, cat: Other, desc: " |- Strategy (RTS/TBS/Grand)"} - - {id: 1054, cat: Other, desc: " |- Strategy Tactical (RTS/TBS/Wargame)"} - - {id: 1053, cat: Other, desc: " |- Strategy (Manage/Busin)"} - - {id: 1052, cat: Other, desc: " |- Racing"} - - {id: 1051, cat: Other, desc: " |- Simulation (Flight/Space)"} - - {id: 1050, cat: Other, desc: " |- Simulation (Sport)"} - - {id: 1049, cat: Other, desc: " |- Simulation (Other)"} - - {id: 1048, cat: Other, desc: " |- AddOn/DLC/Mod для Игр"} - - {id: 1047, cat: Other, desc: " |- Demo/Beta версии Игр"} - - {id: 1046, cat: Other, desc: " |- Языковые пакеты для Игр"} - - {id: 1045, cat: Other, desc: " |- Patch/Tweak/Trainer/Other для Игр"} - - {id: 1044, cat: Other, desc: " |- NoCD/NoDVD/Crack для Игр"} - - {id: 382, cat: Other, desc: "Консольные Игры"} - - {id: 390, cat: Other, desc: " |- Тех. раздел Консолей"} - - {id: 387, cat: Other, desc: " |- Xbox 360"} - - {id: 388, cat: Other, desc: " |- Wii, GameCube"} - - {id: 1264, cat: Other, desc: " |- Wii U"} - - {id: 1318, cat: Other, desc: " |- Switch"} - - {id: 385, cat: Other, desc: " |- PS1"} - - {id: 386, cat: Other, desc: " |- PS2"} - - {id: 848, cat: Other, desc: " |- PS3"} - - {id: 1321, cat: Other, desc: " |- PS4"} - - {id: 383, cat: Other, desc: " |- PSP"} - - {id: 384, cat: Other, desc: " |- Psx to PSP"} - - {id: 1292, cat: Other, desc: " |- PS Vita"} - - {id: 389, cat: Other, desc: " |- Ромы"} - - {id: 391, cat: Other, desc: " |- Другие приставки"} - # pda and mobile - - {id: 1240, cat: Other, desc: " |- Всё для детей и родителей для Android"} - - {id: 830, cat: Other, desc: " |- ПО для Android"} - - {id: 833, cat: Other, desc: " |- Игры для Android"} - - {id: 839, cat: Other, desc: " |- Навигация для Android"} - - {id: 1233, cat: Other, desc: " |- Прошивки для Android устройств"} - - {id: 1236, cat: Other, desc: " |- Вспомогательное ПО для Android"} - - {id: 832, cat: Other, desc: " |- ПО и Игры на Java"} - - {id: 829, cat: Other, desc: " |- ПО и Игры для Symbian"} - - {id: 828, cat: Other, desc: " |- ПО и Игры для Windows Mobile"} - - {id: 1231, cat: Other, desc: " |- Навигация для др. мобильных устройств"} - - {id: 840, cat: Other, desc: " |- Прошивки для др. мобильных устройств"} - - {id: 1232, cat: Other, desc: " |- Вспомогательное ПО для др. мобильных устройств"} - - {id: 841, cat: Other, desc: " |- Прочее для мобильных устройств"} - - {id: 1238, cat: Other, desc: " |- Карты для навигационного ПО"} - - {id: 844, cat: Other, desc: " |- Мобильное Аудио"} - - {id: 842, cat: Other, desc: " |- Мобильное Видео"} - - {id: 843, cat: Other, desc: " |- Темы и Изображения"} - # apple - - {id: 537, cat: Other, desc: " |- macOS (Apple)"} - - {id: 538, cat: Other, desc: " |- macOS (osx86project/hackintosh)"} - - {id: 1151, cat: Other, desc: " |- Разное для macOS (Apple/hackintosh)"} - - {id: 1083, cat: Other, desc: " |- Графика для macOS"} - - {id: 1029, cat: Other, desc: " |- CAD, 3D, ПО для специалистов для macOS"} - - {id: 1082, cat: Other, desc: " |- Офис, Интернет для macOS"} - - {id: 1028, cat: Other, desc: " |- Аудио и видео редакторы для macOS"} - - {id: 1087, cat: Other, desc: " |- Плееры, конвертеры, кодеки для macOS"} - - {id: 1030, cat: Other, desc: " |- Утилиты для macOS"} - - {id: 1039, cat: Other, desc: " |- Kinder Games для macOS"} - - {id: 1038, cat: Other, desc: " |- Quests, Adventure, Arcade для macOS"} - - {id: 1037, cat: Other, desc: " |- Action, FPS для macOS"} - - {id: 1036, cat: Other, desc: " |- Strategy, RPG для macOS"} - - {id: 1035, cat: Other, desc: " |- Racing, Simulation, Sports для macOS"} - - {id: 1034, cat: Other, desc: " |- Casual Games, Other для macOS"} - - {id: 822, cat: Other, desc: " |- Тестовые macOS Игры"} - - {id: 1093, cat: Other, desc: " |- Прошивки iOS и AppleTV"} - - {id: 1092, cat: Other, desc: " |- UnLock, Jailbreak, Cydia"} - - {id: 1091, cat: Other, desc: " |- ПО для iOS"} - - {id: 834, cat: Other, desc: " |- Игры для iOS"} - - {id: 831, cat: Other, desc: " |- ПО из App Store"} - - {id: 1155, cat: Other, desc: " |- Отечественное видео для устройств Apple"} - - {id: 1156, cat: Other, desc: " |- Отечественное видео HD для устройств Apple"} - - {id: 1099, cat: Other, desc: " |- Зарубежное видео для устройств Apple"} - - {id: 1098, cat: Other, desc: " |- Зарубежное видео HD для устройств Apple"} - - {id: 1096, cat: Audio, desc: " |- Музыка (AAC)"} - - {id: 1097, cat: Audio/Lossless, desc: " |- Музыка Lossless (ALAC)"} - - {id: 1095, cat: Audio/Audiobook, desc: " |- Аудиокниги (AAC)"} - # linux - - {id: 536, cat: Other, desc: "Linux, Unix и другие ОС"} - - {id: 563, cat: Other, desc: " |- ОС Linux"} - - {id: 1032, cat: Other, desc: " |- ОС *Nix (Alpha, Beta, RC)"} - - {id: 1031, cat: Other, desc: " |- Сборки ОС Linux"} - - {id: 1025, cat: Other, desc: " |- ОС *BSD"} - - {id: 1026, cat: Other, desc: " |- Другие ОС"} - - {id: 564, cat: Other, desc: " |- *Nix. Программы"} - - {id: 1137, cat: Other, desc: " |- Специализированные ОС"} - - {id: 417, cat: Other, desc: "*Nix Игры"} - - {id: 1193, cat: Other, desc: " |- Native *Nix Games"} - - {id: 1192, cat: Other, desc: " |- Ported *Nix Games"} - # multimedia, designs, graphics - - {id: 1102, cat: Other, desc: "Материалы для мультимедиа и дизайна"} - - {id: 1070, cat: Other, desc: " |- Digital Juice"} - - {id: 534, cat: Other, desc: " |- Звуковые библиотеки"} - - {id: 676, cat: Other, desc: " |- Пресеты"} - - {id: 1077, cat: Other, desc: " |- Дополнения"} - - {id: 267, cat: Other, desc: " |- Футажи"} - - {id: 1071, cat: Other, desc: " |- 3D модели и материалы"} - - {id: 1134, cat: Other, desc: " |- Web-дизайн и шаблоны сайтов"} - - {id: 1107, cat: Other, desc: " |- Текстуры и Фоны"} - - {id: 1075, cat: Other, desc: " |- Шрифты"} - - {id: 1105, cat: Other, desc: " |- Шаблоны и Рамки"} - - {id: 676, cat: Other, desc: " |- Костюмы"} - - {id: 1072, cat: Other, desc: " |- Растровый клипарт (фото)"} - - {id: 166, cat: Other, desc: " |- Растровый клипарт (элементы)"} - - {id: 1078, cat: Other, desc: " |- Растровый клипарт (иллюстрации)"} - - {id: 1074, cat: Other, desc: " |- Векторный клипарт"} - - {id: 1076, cat: Other, desc: " |- Сборники"} - - {id: 1266, cat: Other, desc: "Проекты"} - - {id: 1267, cat: Other, desc: " |- Детские (проекты)"} - - {id: 1268, cat: Other, desc: " |- Свадебные и романтические (проекты)"} - - {id: 1269, cat: Other, desc: " |- Праздничные (проекты)"} - - {id: 1270, cat: Other, desc: " |- Презентации (проекты)"} - - {id: 1277, cat: Other, desc: " |- Трейлеры (проекты)"} - - {id: 1271, cat: Other, desc: " |- Спортивные (проекты)"} - - {id: 1272, cat: Other, desc: " |- Логотипы (проекты)"} - - {id: 1273, cat: Other, desc: " |- Слайдшоу (проекты)"} - - {id: 1274, cat: Other, desc: " |- Титры (проекты)"} - - {id: 1275, cat: Other, desc: " |- Элементы (проекты)"} - - {id: 1276, cat: Other, desc: " |- Разное (проекты)"} - - {id: 1103, cat: Other, desc: "Графика, Арт, Разное"} - - {id: 1114, cat: Other, desc: " |- Классическое искусство"} - - {id: 1113, cat: Other, desc: " |- Современное изобразительное искусство"} - - {id: 1115, cat: Other, desc: " |- Книжная иллюстрация"} - - {id: 1129, cat: Other, desc: " |- Современная фотография"} - - {id: 1111, cat: Other, desc: " |- Иконки и аватарки"} - - {id: 1116, cat: Other, desc: " |- Картинки"} - - {id: 808, cat: Other, desc: " |- Обои для рабочего стола"} - - {id: 1139, cat: Other, desc: " |- Обои для рабочего стола (16+)"} - - {id: 988, cat: Other, desc: " |- Графика (16+)"} - - {id: 1073, cat: Other, desc: " |- Интернет-творчество"} - # temp, archive - - {id: 892, cat: Other, desc: " |- Архив Мультфильмов"} - - {id: 91, cat: Other, desc: " |- Архив Видео. Кино, Театра"} - - {id: 668, cat: Other, desc: " |- Архив Классики кино и Старых фильмов до 90-х"} - - {id: 1143, cat: Other, desc: " |- Архив Музыкального Видео"} - - {id: 802, cat: Other, desc: " |- Архив Сериалов и Архив Старого многосерийного кино до 90-х"} - - {id: 669, cat: Other, desc: " |- Архив Документалистики и TV"} - - {id: 400, cat: Other, desc: " |- Архив Юмора"} - - {id: 169, cat: Other, desc: " |- Архив Аниме"} - - {id: 94, cat: Other, desc: " |- Архив Книг и обучающих материалов"} - - {id: 303, cat: Other, desc: " |- Архив Авто"} - - {id: 92, cat: Other, desc: " |- Архив Музыки"} - - {id: 93, cat: Other, desc: " |- Архив Игр"} - - {id: 1333, cat: Other, desc: " |- Архив *Nix"} - - {id: 95, cat: Other, desc: " |- Архив Программ"} - - {id: 184, cat: Other, desc: " |- Архив КПК и Мобильных устройств"} - - {id: 1080, cat: Other, desc: " |- Архив 4Apple"} - - {id: 180, cat: Other, desc: " |- Архив Медиа-Диз-Графика"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -search: - paths: - - path: forum/tracker.php - method: post - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tm: -1 - shf: 1 - sha: 1 - ta: -1 - sns: -1 - sds: 4 # only freeleech available for download without account - nm: "{{ .Keywords }}" - submit: "Поиск" - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: table.forumline.tablesorter > tbody > tr:has(a[href^="viewtopic.php?t="]):has(a[href^="download.php?id="]) - - fields: - category_id: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - category: - text: "{{ .Result.category_id }}" - title: - selector: a[href^="viewtopic.php?t="] > b - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(?i)\\bКураж-Бамбей\\b", "kurazh"] - - name: re_replace - args: ["(?i)\\bКубик в Кубе\\b", "Kubik"] - - name: re_replace - args: ["(?i)\\bКравец\\b", "Kravec"] - - name: re_replace - args: ["(?i)\\bПифагор\\b", "Pifagor"] - - name: re_replace - args: ["(?i)\\bНевафильм\\b", "Nevafilm"] - - name: re_replace - args: ["(?i)\\bЛицензия\\b", "Lic"] - - name: re_replace - args: ["(?i)\\bселезень\\b", "selezen"] - - name: re_replace - args: ["(?i)\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if and (ne .Result.category_id \"913\") (.Config.addrussiantotitle) }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="viewtopic.php?t="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - grabs: - selector: td:nth-child(9) - date: - # unix - selector: td:last-child > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="viewtopic.php?t="] > b -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/noname-clubl.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/noname-clubl.yml deleted file mode 100644 index e813efa1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/noname-clubl.yml +++ /dev/null @@ -1,929 +0,0 @@ ---- -id: noname-clubl -name: NoNaMe ClubL -description: "This is the NoNaMe Club indexer with Login enabled in the config." -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - https://nnmclub.to/ -legacylinks: - - https://nnm-club.name/ - - https://nnm-club.me/ - - http://nnmclub.to/ - -caps: - categorymappings: - # forum - - {id: 48, cat: Other, desc: "Форум Региональные встречи"} - # forum tracker - - {id: 925, cat: Other, desc: "Форум-Трекер: Клубные таланты Авторские релизы"} - - {id: 872, cat: Other, desc: "Форум-Трекер: Клубные таланты Архив"} - # Everything for children and parents - - {id: 724, cat: TV, desc: "Видео, Кино и Сериалы для детей и родителей"} - - {id: 725, cat: TV, desc: " |- Обучающее Видео для родителей"} - - {id: 729, cat: TV, desc: " |- Развивающее Видео для детей"} - - {id: 731, cat: TV, desc: " |- Отечественные Фильмы и Сериалы для детей (SD)"} - - {id: 1345, cat: TV, desc: " |- Отечественные Фильмы и Сериалы для детей (HD, FHD, UHD)"} - - {id: 733, cat: TV, desc: " |- Зарубежные Фильмы и Сериалы для детей (SD)"} - - {id: 1346, cat: TV, desc: " |- Зарубежные Фильмы и Сериалы для детей (HD, FHD, UHD)"} - - {id: 1329, cat: TV, desc: " |- Отечественные Мультфильмы 20-го века (SD)"} - - {id: 1330, cat: TV, desc: " |- Отечественные Мультфильмы 20-го века (HD, FHD, UHD)"} - - {id: 1331, cat: TV, desc: " |- Отечественные Мультфильмы 21-го века (SD)"} - - {id: 1332, cat: TV, desc: " |- Отечественные Мультфильмы 21-го века (HD, FHD, UHD, 3D)"} - - {id: 1340, cat: TV, desc: " |- Отечественные Мультфильмы (коллекции, сборники / *логии)"} - - {id: 658, cat: TV, desc: " |- Отечественные Мультсериалы"} - - {id: 890, cat: TV, desc: " |- Мультфильмы (3D)"} - - {id: 1336, cat: TV, desc: " |- Зарубежные Мультфильмы 20-го века (SD)"} - - {id: 1337, cat: TV, desc: " |- Зарубежные Мультфильмы 20-го века (HD, FHD, UHD)"} - - {id: 1338, cat: TV, desc: " |- Зарубежные Мультфильмы 21-го века (SD)"} - - {id: 1339, cat: TV, desc: " |- Зарубежные Мультфильмы 21-го века (HD, FHD, UHD)"} - - {id: 660, cat: TV, desc: " |- Зарубежные Мультфильмы (коллекции, сборники / *логии)"} - - {id: 232, cat: Other, desc: " |- Зарубежные Мультсериалы"} - - {id: 734, cat: Other, desc: " |- Классика для мам и малышей"} - - {id: 742, cat: Other, desc: " |- Музыка и песни для детей"} - - {id: 735, cat: Other, desc: " |- Аудиокниги для детей и родителей"} - - {id: 738, cat: Other, desc: " |- Образование, обучение и развитие детей"} - - {id: 967, cat: Other, desc: " |- Логопедия, Психология, Педиатрия"} - - {id: 907, cat: Other, desc: " |- Журналы для детей и родителей"} - - {id: 739, cat: Other, desc: " |- Детская литература"} - - {id: 1109, cat: Other, desc: " |- Энциклопедии и Познавательная литература для детей"} - - {id: 736, cat: Other, desc: " |- Мультимедийные материалы для родителей"} - - {id: 737, cat: Other, desc: " |- Мультимедийные материалы для детей"} - - {id: 898, cat: Other, desc: " |- Рабочие тетради, прописи и разукрашки"} - - {id: 935, cat: Other, desc: " |- Настольные игры и Поделки"} - - {id: 871, cat: Other, desc: " |- Подвижные Игры"} - - {id: 973, cat: Other, desc: " |- Изобразительное искусство"} - - {id: 960, cat: Other, desc: " |- Прочие материалы для детей и родителей"} - - {id: 1239, cat: Other, desc: " |- ГИА, ОГЭ, ЕГЭ, ЕМЭ, ГВЭ"} - - {id: 740, cat: Other, desc: " |- Развивающие Игры для детей"} - - {id: 741, cat: Other, desc: " |- Детские Игры"} - # programs - - {id: 503, cat: Other, desc: "ОС Windows"} - - {id: 504, cat: Other, desc: " |- Оригинальные версии Windows"} - - {id: 506, cat: Other, desc: " |- Оригинальные версии Windows Server"} - - {id: 763, cat: Other, desc: " |- Windows OEM Recovery СD/DVD"} - - {id: 1335, cat: Other, desc: " |- Сборки Windows 11"} - - {id: 1241, cat: Other, desc: " |- Сборки Windows 10"} - - {id: 1023, cat: Other, desc: " |- Сборки Windows 8"} - - {id: 717, cat: Other, desc: " |- Сборки Windows 7"} - - {id: 509, cat: Other, desc: " |- Сборки Windows Vista"} - - {id: 508, cat: Other, desc: " |- Сборки Windows ХР"} - - {id: 510, cat: Other, desc: " |- Сборки Windows - всё в одном"} - - {id: 1254, cat: Other, desc: " |- Сборки Windows для незрячих"} - - {id: 1042, cat: Other, desc: " |- Песочница ПО и сборок Windows"} - - {id: 511, cat: Other, desc: " |- Разное (RC, Beta и Service Packs)"} - - {id: 916, cat: Other, desc: " |- Музей Windows"} - - {id: 512, cat: Other, desc: "Утилиты, Офис, Интернет"} - - {id: 561, cat: Other, desc: " |- ПО для Интернета и сетей"} - - {id: 1284, cat: Other, desc: " |- Оригинальные версии Office"} - - {id: 562, cat: Other, desc: " |- Офисное ПО"} - - {id: 513, cat: Other, desc: " |- Запись, создание, редактирование, эмуляция дисков и..."} - - {id: 514, cat: Other, desc: " |- Диагностика и обслуживание hardware"} - - {id: 515, cat: Other, desc: " |- Резервирование и восстановление данных"} - - {id: 516, cat: Other, desc: " |- Файловые менеджеры и архиваторы"} - - {id: 517, cat: Other, desc: " |- Обслуживание ОС"} - - {id: 518, cat: Other, desc: " |- Разное (Утилиты, Офис, Интернет)"} - - {id: 519, cat: Other, desc: "Безопасность"} - - {id: 520, cat: Other, desc: " |- Firewalls"} - - {id: 521, cat: Other, desc: " |- Антивирусы"} - - {id: 522, cat: Other, desc: " |- Комплексные системы защиты"} - - {id: 523, cat: Other, desc: " |- Разное (остальные программы по безопасности)"} - - {id: 524, cat: Other, desc: "Мультимедиа и Графика"} - - {id: 532, cat: Other, desc: " |- Аудио Плееры и Кодеки"} - - {id: 533, cat: Other, desc: " |- Аудио Граббинг, Мастеринг, Обработка"} - - {id: 535, cat: Other, desc: " |- Прочее ПО для Аудио"} - - {id: 530, cat: Other, desc: " |- Видео Плееры и Кодеки"} - - {id: 529, cat: Other, desc: " |- Нелинейный Видеомонтаж, Авторинг, Кодировщики"} - - {id: 525, cat: Other, desc: " |- Просмотрщики Графики (вьюверы)"} - - {id: 526, cat: Other, desc: " |- Графические редакторы"} - - {id: 527, cat: Other, desc: " |- ПО для моделирования"} - - {id: 545, cat: Other, desc: "Софт и оболочки для специалистов, Прочее"} - - {id: 764, cat: Other, desc: " |- LiveCD/DVD/Flash"} - - {id: 765, cat: Other, desc: " |- WPI"} - - {id: 820, cat: Other, desc: " |- Серверное ПО"} - - {id: 552, cat: Other, desc: " |- Разработка ПО"} - - {id: 553, cat: Other, desc: " |- САПР/ГИС"} - - {id: 554, cat: Other, desc: " |- Остальное ПО для специалистов"} - - {id: 550, cat: Other, desc: " |- Системы навигации и карты"} - - {id: 549, cat: Other, desc: " |- Драйверы"} - - {id: 548, cat: Other, desc: " |- Разное (прочее ПО)"} - # Movies - - {id: 216, cat: Movies, desc: "Горячие новинки"} - - {id: 270, cat: Movies, desc: " |- Отечественные Новинки (SD, DVD)"} - - {id: 218, cat: Movies, desc: " |- Зарубежные Новинки (SD, DVD)"} - - {id: 219, cat: Movies, desc: " |- Отечественные Новинки (HD, FHD, UHD, 3D)"} - - {id: 954, cat: Movies, desc: " |- Зарубежные Новинки (HD, FHD, UHD, 3D)"} - - {id: 217, cat: Movies, desc: " |- Экранки"} - - {id: 1293, cat: Movies, desc: " |- Новинки с Рекламой"} - - {id: 1298, cat: Movies, desc: " |- Экранки с рекламой"} - - {id: 318, cat: Movies, desc: "Классика кино и Старые фильмы до 90-х"} - - {id: 320, cat: Movies, desc: " |- Отечественная Классика (SD)"} - - {id: 677, cat: Movies, desc: " |- Отечественная Классика (DVD)"} - - {id: 1177, cat: Movies, desc: " |- Отечественная Классика (HD, FHD, UHD)"} - - {id: 319, cat: Movies, desc: " |- Зарубежная Классика (SD)"} - - {id: 678, cat: Movies, desc: " |- Зарубежная Классика (DVD)"} - - {id: 885, cat: Movies, desc: " |- Зарубежная Классика (HD, FHD, UHD, 3D)"} - - {id: 908, cat: Movies, desc: " |- Старые Отечественные Фильмы (SD)"} - - {id: 1310, cat: Movies, desc: " |- Старые Отечественные Фильмы (DVD)"} - - {id: 909, cat: Movies, desc: " |- Старые Отечественные Фильмы (HD, FHD, UHD)"} - - {id: 910, cat: Movies, desc: " |- Старые Зарубежные Фильмы (SD)"} - - {id: 911, cat: Movies, desc: " |- Старые Зарубежные Фильмы (DVD)"} - - {id: 912, cat: Movies, desc: " |- Старые Зарубежные Фильмы (HD, FHD, UHD, 3D)"} - - {id: 220, cat: Movies, desc: "Отечественное кино"} - - {id: 221, cat: Movies, desc: " |- Отечественные Фильмы (SD)"} - - {id: 222, cat: Movies, desc: " |- Отечественные Фильмы (DVD)"} - - {id: 882, cat: Movies, desc: " |- Отечественные Фильмы (HD, FHD, UHD)"} - - {id: 889, cat: Movies, desc: " |- Отечественные Фильмы (3D)"} - - {id: 224, cat: Movies, desc: "Зарубежное кино"} - - {id: 225, cat: Movies, desc: " |- Зарубежные Фильмы (SD)"} - - {id: 226, cat: Movies, desc: " |- Зарубежные Фильмы (DVD)"} - - {id: 227, cat: Movies, desc: " |- Зарубежные Фильмы (HD, FHD)"} - - {id: 1296, cat: Movies, desc: " |- Зарубежные Фильмы (UHD)"} - - {id: 891, cat: Movies, desc: " |- Зарубежные Фильмы (3D)"} - - {id: 1299, cat: Movies, desc: " |- Фильмы ближнего зарубежья"} - - {id: 682, cat: Movies, desc: " |- Азиатское кино (SD)"} - - {id: 694, cat: Movies, desc: " |- Азиатское кино (DVD)"} - - {id: 884, cat: Movies, desc: " |- Азиатское кино (HD, FHD, UHD)"} - - {id: 1211, cat: Movies, desc: " |- Азиатское кино (3D)"} - - {id: 693, cat: Movies, desc: " |- Индийское кино"} - - {id: 913, cat: Movies, desc: " |- Фильмы с переводом на др. языках"} - - {id: 228, cat: Movies, desc: " |- Фильмы в оригинале (SD, DVD)"} - - {id: 1150, cat: Movies, desc: " |- Фильмы в оригинале (HD, FHD, UHD)"} - - {id: 1311, cat: Movies, desc: "Коллекции / *логии"} - - {id: 1313, cat: Movies, desc: " |- Зарубежное кино (коллекции / *логии)"} - - {id: 1312, cat: Movies, desc: " |- Отечественное кино (коллекции / *логии)"} - - {id: 256, cat: Movies, desc: "Театр и Музыкальное видео"} - - {id: 257, cat: Movies, desc: " |- Музыкальные клипы"} - - {id: 258, cat: Movies, desc: " |- Концерты (SD)"} - - {id: 883, cat: Movies, desc: " |- Концерты (DVD)"} - - {id: 955, cat: Movies, desc: " |- Концерты (HD, FHD, UHD, 3D)"} - - {id: 905, cat: Movies, desc: " |- Театр"} - - {id: 271, cat: Movies, desc: " |- Опера, Балет, Мюзиклы"} - - {id: 1210, cat: Movies, desc: " |- Караоке"} - - {id: 264, cat: Movies, desc: "Остальное"} - - {id: 265, cat: Movies, desc: " |- Звуковые дорожки и субтитры"} - - {id: 272, cat: Movies, desc: " |- Игровое видео"} - - {id: 1262, cat: Movies, desc: " |- hand made * video"} - - {id: 266, cat: Movies, desc: " |- Трейлеры"} - - {id: 1294, cat: Movies, desc: " |- Фильмы с Рекламой"} - # TV - - {id: 1219, cat: TV, desc: "Классика сериалов и многосерийное кино до 90-х"} - - {id: 1221, cat: TV, desc: " |- Отечественная классика сериалов и старое многосерийное..."} - - {id: 1220, cat: TV, desc: " |- Зарубежная классика сериалов и старое многосерийное кино..."} - - {id: 722, cat: TV, desc: " |- Чертова служба в госпитале МЭШ / M*A*S*H"} - - {id: 768, cat: TV, desc: "Зарубежные сериалы"} - - {id: 1344, cat: Other, desc: " |- Звездные войны / Star Wars (сериалы по франшизе)"} - - {id: 779, cat: TV, desc: " |- Анатомия страсти / Grey's Anatomy"} - - {id: 1288, cat: TV, desc: " |- Во все тяжкие / Breaking Bad; Лучше звоните Солу / Better..."} - - {id: 787, cat: TV, desc: " |- Грань / Fringe"} - - {id: 1141, cat: TV, desc: " |- Дневники вампира / Vampire Diaries; Настоящая кровь /..."} - - {id: 777, cat: TV, desc: " |- Доктор кто / Doctor Who; Торчвуд / Torchwood"} - - {id: 786, cat: TV, desc: " |- Доктор Хаус / House M.D."} - - {id: 776, cat: TV, desc: " |- Звездные врата / Stargate"} - - {id: 785, cat: TV, desc: " |- Звездный Крейсер Галактика / BattleStar Galactica;..."} - - {id: 775, cat: TV, desc: " |- Звездный путь / Star Trek; Орвилл / The Orville"} - - {id: 1265, cat: TV, desc: " |- Игра престолов / Game of Thrones"} - - {id: 1242, cat: TV, desc: " |- Касл / Castle"} - - {id: 1140, cat: TV, desc: " |- Кости / Bones"} - - {id: 782, cat: TV, desc: " |- Менталист / The Mentalist; Теория Лжи / Lie To Me"} - - {id: 773, cat: TV, desc: " |- Место преступления / CSI"} - - {id: 1142, cat: TV, desc: " |- Морская полиция / Navy NCIS; Военно-юридическая служба /..."} - - {id: 772, cat: TV, desc: " |- Побег / Prison Break"} - - {id: 771, cat: TV, desc: " |- Пуаро / Poirot"} - - {id: 783, cat: TV, desc: " |- Сверхъестественное / Supernatural"} - - {id: 1144, cat: TV, desc: " |- Секретные материалы / X-Files"} - - {id: 804, cat: TV, desc: " |- Теория Большого Взрыва / The Big Bang Theory; Детство..."} - - {id: 1290, cat: TV, desc: " |- Ходячие мертвецы / The Walking Dead; Бойтесь ходячих..."} - - {id: 1300, cat: TV, desc: " |- Сериалы ближнего зарубежья"} - - {id: 784, cat: TV, desc: " |- Сериалы DC Comics"} - - {id: 774, cat: TV, desc: " |- Сериалы Marvel Comics"} - - {id: 922, cat: TV, desc: " |- Азиатские сериалы"} - - {id: 770, cat: TV, desc: " |- Латиноамериканские сериалы"} - - {id: 1320, cat: TV, desc: " |- Турецкие сериалы"} - - {id: 780, cat: TV, desc: " |- Сериалы без русского перевода (украинская озвучка)"} - - {id: 781, cat: TV, desc: " |- Сериалы без перевода"} - - {id: 1322, cat: TV, desc: " |- Сериалы с рекламой"} - - {id: 769, cat: TV, desc: "Отечественные сериалы"} - - {id: 799, cat: TV, desc: " |- Бандитский Петербург"} - - {id: 800, cat: TV, desc: " |- Глухарь"} - - {id: 791, cat: TV, desc: " |- Интерны"} - - {id: 793, cat: TV, desc: " |- Ментовские войны"} - - {id: 794, cat: TV, desc: " |- Менты"} - - {id: 796, cat: TV, desc: " |- Солдаты"} - - {id: 795, cat: TV, desc: " |- Универ"} - # Documentary, Telecasts, Sports, Comedy - - {id: 713, cat: TV, desc: "Зарубежные TV-бренды"} - - {id: 706, cat: TV, desc: " |- Animal Planet"} - - {id: 577, cat: TV, desc: " |- BBC"} - - {id: 894, cat: TV, desc: " |- Da Vinci Learning"} - - {id: 578, cat: TV, desc: " |- Discovery"} - - {id: 580, cat: TV, desc: " |- History Channel"} - - {id: 579, cat: TV, desc: " |- National Geographic"} - - {id: 953, cat: TV, desc: " |- PBS"} - - {id: 581, cat: TV, desc: " |- Readers Digest"} - - {id: 806, cat: TV, desc: " |- Интересно обо всем"} - - {id: 714, cat: TV, desc: " |- Мега-Проекты"} - - {id: 761, cat: TV, desc: " |- Доисторический мир"} - - {id: 809, cat: TV, desc: " |- Мир будущего"} - - {id: 924, cat: TV, desc: " |- Одиссея Жака Кусто"} - - {id: 812, cat: TV, desc: " |- Тайны и Загадки"} - - {id: 576, cat: TV, desc: "Документалистика и Телепередачи"} - - {id: 590, cat: TV, desc: " |- Кинолетопись Страны Советской"} - - {id: 591, cat: TV, desc: " |- Вторая Мировая война"} - - {id: 588, cat: TV, desc: " |- Отечественная история"} - - {id: 589, cat: TV, desc: " |- История"} - - {id: 598, cat: TV, desc: " |- Личности в истории"} - - {id: 652, cat: TV, desc: " |- Посвящение искусству"} - - {id: 599, cat: TV, desc: " |- Телешоу"} - - {id: 959, cat: TV, desc: " |- Музыкальные шоу"} - - {id: 956, cat: TV, desc: " |- Интеллектуальные шоу"} - - {id: 597, cat: TV, desc: " |- Непознанное и сверхъестественное"} - - {id: 593, cat: TV, desc: " |- Живая природа"} - - {id: 594, cat: TV, desc: " |- Клуб кинопутешествий"} - - {id: 819, cat: TV, desc: " |- За рулем"} - - {id: 595, cat: TV, desc: " |- Релакс / Relax"} - - {id: 587, cat: TV, desc: " |- Военное дело"} - - {id: 584, cat: TV, desc: " |- Авиация"} - - {id: 586, cat: TV, desc: " |- Космос"} - - {id: 585, cat: TV, desc: " |- Флот"} - - {id: 600, cat: TV, desc: " |- Тележурналистика"} - - {id: 596, cat: TV, desc: " |- Политика и пропаганда, публицистика и экономика"} - - {id: 1295, cat: TV, desc: " |- Общественно-политические и пропагандистские ток-шоу"} - - {id: 614, cat: TV, desc: " |- Религии и культы (Док/TV)"} - - {id: 603, cat: TV, desc: "Спорт и активный отдых"} - - {id: 1308, cat: TV, desc: " |- Хоккей. Чемпионат мира"} - - {id: 1309, cat: TV, desc: " |- Футбол. Чемпионат Европы"} - - {id: 1206, cat: TV, desc: " |- Футбол. Чемпионат Мира"} - - {id: 1194, cat: TV, desc: " |- Летние Олимпийские игры и Паралимпийские игры"} - - {id: 1062, cat: TV, desc: " |- Зимние Олимпийские игры и Паралимпийские игры"} - - {id: 974, cat: TV, desc: " |- Футбол"} - - {id: 609, cat: TV, desc: " |- Баскетбол, Волейбол, Гандбол"} - - {id: 1263, cat: TV, desc: " |- Хоккей"} - - {id: 951, cat: TV, desc: " |- Прочие зимние виды спорта"} - - {id: 975, cat: TV, desc: " |- Бокс"} - - {id: 608, cat: TV, desc: " |- Единоборства, Бои без правил"} - - {id: 607, cat: TV, desc: " |- Гимнастика, Бодибилдинг, Красота тела"} - - {id: 606, cat: TV, desc: " |- Авто, Мото"} - - {id: 750, cat: TV, desc: " |- Формула 1"} - - {id: 605, cat: TV, desc: " |- Экстрим"} - - {id: 604, cat: TV, desc: " |- Рыбалка и Охота"} - - {id: 950, cat: TV, desc: " |- Бильярд"} - - {id: 610, cat: TV, desc: "Юмор (ТВ)"} - - {id: 613, cat: TV, desc: " |- КВН"} - - {id: 612, cat: TV, desc: " |- ПостКВН"} - - {id: 653, cat: TV, desc: " |- Украинские шоу"} - - {id: 654, cat: TV, desc: " |- Маски-шоу"} - - {id: 611, cat: TV, desc: " |- Сатирики"} - - {id: 656, cat: TV, desc: " |- Приколы"} - # anime - - {id: 615, cat: TV/Anime, desc: "Манга и Арт"} - - {id: 616, cat: TV/Anime, desc: " |- Манга, Манхва, Маньхуа"} - - {id: 617, cat: TV/Anime, desc: " |- Ранобэ"} - - {id: 648, cat: TV/Anime, desc: " |- Визуальные новеллы"} - - {id: 619, cat: TV/Anime, desc: " |- Аниме арт"} - - {id: 620, cat: TV/Anime, desc: "Аниме с субтитрами"} - - {id: 623, cat: TV/Anime, desc: " |- Онгоинги"} - - {id: 622, cat: TV/Anime, desc: " |- Аниме (SD)"} - - {id: 621, cat: TV/Anime, desc: " |- Аниме (HD)"} - - {id: 632, cat: TV/Anime, desc: " |- Аниме (FullHD)"} - - {id: 624, cat: TV/Anime, desc: "Аниме с озвучкой"} - - {id: 627, cat: TV/Anime, desc: " |- Онгоинги с озвучкой"} - - {id: 626, cat: TV/Anime, desc: " |- Аниме с озвучкой (SD)"} - - {id: 625, cat: TV/Anime, desc: " |- Аниме с озвучкой (HD)"} - - {id: 644, cat: TV/Anime, desc: " |- Аниме с озвучкой (FullHD)"} - - {id: 628, cat: TV/Anime, desc: "Аниме разное"} - - {id: 635, cat: TV/Anime, desc: " |- Аниме DVD"} - - {id: 634, cat: TV/Anime, desc: " |- Аниме Blu-ray, Remux"} - - {id: 638, cat: TV/Anime, desc: " |- Аниме хардсаб"} - - {id: 646, cat: TV/Anime, desc: " |- Аниме прочее"} - - {id: 645, cat: TV/Anime, desc: "Аниме музыка"} - - {id: 639, cat: TV/Anime, desc: " |- Аниме OST (Lossless)"} - - {id: 640, cat: TV/Anime, desc: " |- Аниме OST"} - # Books and Training Materials - - {id: 432, cat: Books, desc: "Научная и техническая литература"} - - {id: 755, cat: Books, desc: " |- Учебники"} - - {id: 481, cat: Books, desc: " |- Иностранные языки (литература)"} - - {id: 557, cat: Books, desc: " |- Гуманитарные науки и искусство"} - - {id: 442, cat: Books, desc: " |- Точные и естественные науки"} - - {id: 441, cat: Books, desc: " |- Техническая литература"} - - {id: 875, cat: Books, desc: " |- Военно-историческая литература"} - - {id: 1176, cat: Books, desc: " |- Историческая литература"} - - {id: 444, cat: Books, desc: " |- Научно-популярная литература"} - - {id: 443, cat: Books, desc: " |- Здоровье и медицина"} - - {id: 440, cat: Books, desc: " |- Нормативная документация"} - - {id: 1199, cat: Books, desc: " |- Энциклопедии и словари"} - - {id: 433, cat: Books, desc: "Компьютерная литература"} - - {id: 447, cat: Books, desc: " |- Программирование"} - - {id: 445, cat: Books, desc: " |- Веб-дизайн"} - - {id: 817, cat: Books, desc: " |- 2D графика"} - - {id: 818, cat: Books, desc: " |- 3D графика"} - - {id: 434, cat: Books, desc: "Художественная литература"} - - {id: 1349, cat: Books, desc: " |- Библиотеки"} - - {id: 957, cat: Books, desc: " |- Многоавторские сборники и Библиотеки"} - - {id: 931, cat: Books, desc: " |- Собрания книг русскоязычных авторов"} - - {id: 1152, cat: Books, desc: " |- Собрания книг иностранных авторов"} - - {id: 455, cat: Books, desc: " |- Сатира, Юмор"} - - {id: 453, cat: Books, desc: " |- Боевики, Детективы, Триллеры"} - - {id: 1063, cat: Books, desc: " |- Приключенческая проза "} - - {id: 452, cat: Books, desc: " |- Историческая проза, Мифы и Легенды"} - - {id: 451, cat: Books, desc: " |- Фантастика, Фэнтези"} - - {id: 449, cat: Books, desc: " |- Современная поэзия и проза"} - - {id: 1153, cat: Books, desc: " |- Классическая поэзия и проза"} - - {id: 1347, cat: Books, desc: "Книги вне издательств, самиздат (все жанры)"} - - {id: 482, cat: Books, desc: "Комиксы и Артбуки"} - - {id: 483, cat: Books, desc: " |- Комиксы на русском языке"} - - {id: 484, cat: Books, desc: " |- Комиксы без перевода"} - - {id: 1343, cat: Books, desc: " |- Артбуки"} - - {id: 438, cat: Books, desc: "Художественные аудиокниги и публицистика"} - - {id: 485, cat: Books, desc: " |- Сатира, Юмор (аудиокниги)"} - - {id: 473, cat: Books, desc: " |- Детектив, Боевик (аудиокниги)"} - - {id: 472, cat: Books, desc: " |- Исторические аудиокниги"} - - {id: 471, cat: Books, desc: " |- Классика (аудиокниги)"} - - {id: 895, cat: Books, desc: " |- Проза, Поэзия (аудиокниги)"} - - {id: 470, cat: Books, desc: " |- Фантастика, Фэнтези (аудиокниги)"} - - {id: 896, cat: Books, desc: " |- Публицистика (аудиокниги)"} - - {id: 480, cat: Books, desc: " |- Другие аудиокниги"} - - {id: 436, cat: Audio/Audiobook, desc: "Обучающие аудиоматериалы"} - - {id: 458, cat: Audio/Audiobook, desc: " |- Бизнес и Менеджмент (аудиоматериалы)"} - - {id: 457, cat: Audio/Audiobook, desc: " |- Иностранные языки (аудиоматериалы)"} - - {id: 1342, cat: Audio/Audiobook, desc: " |- Здоровье и Медицина (аудиоматериалы)"} - - {id: 459, cat: Audio/Audiobook, desc: " |- Популярная психология и саморазвитие (аудиоматериалы)"} - - {id: 460, cat: Audio/Audiobook, desc: " |- Медитации (аудиоматериалы)"} - - {id: 461, cat: Audio/Audiobook, desc: " |- Религия (аудиоматериалы)"} - - {id: 462, cat: Audio/Audiobook, desc: " |- Прочие аудиоматериалы"} - - {id: 437, cat: Books, desc: "Обучающие видеоматериалы"} - - {id: 466, cat: TV, desc: " |- Бизнес и Менеджмент (видеокурсы)"} - - {id: 1319, cat: TV, desc: " |- Иностранные языки (видеокурсы)"} - - {id: 463, cat: TV, desc: " |- IT, Компьютерные видеокурсы"} - - {id: 958, cat: TV, desc: " |- Дизайн, рисование (видеокурсы)"} - - {id: 1223, cat: TV, desc: " |- Фотография и Видео (видеокурсы)"} - - {id: 467, cat: TV, desc: " |- Здоровье и Спорт (видеокурсы)"} - - {id: 464, cat: TV, desc: " |- Психология и саморазвитие (видеокурсы)"} - - {id: 465, cat: TV, desc: " |- Музыка (видеокурсы)"} - - {id: 1348, cat: TV, desc: " |- Домоводство, строительство и ремонт (видеокурсы)"} - - {id: 469, cat: TV, desc: " |- Другие видеокурсы"} - - {id: 439, cat: Books, desc: "Мультимедийные материалы"} - - {id: 477, cat: Books, desc: " |- Образование"} - - {id: 476, cat: Books, desc: " |- Иностранные языки"} - - {id: 475, cat: Books, desc: " |- Компьютеры"} - - {id: 474, cat: Books, desc: " |- Мультимедийные справочники, Энциклопедии"} - - {id: 886, cat: Books, desc: " |- Приложения к журналам"} - - {id: 478, cat: Books, desc: " |- Другие мультимедийные материалы"} - - {id: 486, cat: Books, desc: "Журналы"} - - {id: 490, cat: Books, desc: " |- Мужские журналы"} - - {id: 657, cat: Books, desc: " |- Женские журналы"} - - {id: 489, cat: Books, desc: " |- Игровые журналы"} - - {id: 488, cat: Books, desc: " |- Компьютерные журналы"} - - {id: 487, cat: Books, desc: " |- Научно-популярные журналы"} - - {id: 1198, cat: Books, desc: " |- Журналы по электротехнике и радиоэлектронике"} - - {id: 1227, cat: Books, desc: " |- Кулинарные журналы"} - - {id: 893, cat: Books, desc: " |- Домоводство (журналы)"} - - {id: 491, cat: Books, desc: " |- Хобби (журналы)"} - - {id: 767, cat: Books, desc: " |- Другие журналы"} - - {id: 299, cat: Books, desc: "Автомобили"} - - {id: 887, cat: Books, desc: " |- Автомобильные журналы"} - - {id: 301, cat: Books, desc: " |- Автомобильная литература"} - - {id: 1334, cat: Books, desc: " |- Автомобильные обуч.видео"} - - {id: 300, cat: Books, desc: " |- Автомобильные мультимедийные материалы"} - - {id: 1341, cat: Books, desc: " |- Автомобильные программы и навигаторы"} - - {id: 492, cat: Books, desc: "Разное (категория книг)"} - - {id: 558, cat: Books, desc: " |- Бизнес, Менеджмент, Деловая литература"} - - {id: 1173, cat: Books, desc: " |- Публицистика"} - - {id: 1174, cat: Books, desc: " |- Популярная психология и саморазвитие"} - - {id: 1171, cat: Books, desc: " |- Эзотерика"} - - {id: 662, cat: Books, desc: " |- Религиозная литература"} - - {id: 1175, cat: Books, desc: " |- Нетрадиционная медицина"} - - {id: 1172, cat: Books, desc: " |- Строительство и ремонт"} - - {id: 933, cat: Books, desc: " |- Сад, огород, животноводство"} - - {id: 815, cat: Books, desc: " |- Кулинария"} - - {id: 1170, cat: Books, desc: " |- Спорт, Фитнес, Боевые искусства"} - - {id: 398, cat: Books, desc: " |- Ноты и обучение музыке"} - - {id: 816, cat: Books, desc: " |- На досуге"} - # Music - - {id: 313, cat: Audio, desc: "HD Audio и Многоканальная Музыка"} - - {id: 1291, cat: Audio, desc: " |- Blu-ray Audio"} - - {id: 680, cat: Audio, desc: " |- DVD-Audio"} - - {id: 1149, cat: Audio, desc: " |- SACD-R"} - - {id: 429, cat: Audio, desc: " |- DTS-Audio"} - - {id: 681, cat: Audio, desc: " |- Vinyl-Rip и Hand-Made"} - - {id: 330, cat: Audio, desc: "Классика"} - - {id: 1256, cat: Audio, desc: " |- Классика (HD Audio)"} - - {id: 1285, cat: Audio/Lossless, desc: " |- Полные собрания сочинений (Lossless)"} - - {id: 370, cat: Audio, desc: " |- Полные собрания сочинений"} - - {id: 1260, cat: Audio/Lossless, desc: " |- Вокал (Lossless)"} - - {id: 371, cat: Audio, desc: " |- Вокал"} - - {id: 1261, cat: Audio/Lossless, desc: " |- Концерты (Lossless)"} - - {id: 375, cat: Audio, desc: " |- Концерты"} - - {id: 1259, cat: Audio/Lossless, desc: " |- Оркестровая (Lossless)"} - - {id: 374, cat: Audio, desc: " |- Оркестровая"} - - {id: 1257, cat: Audio/Lossless, desc: " |- Камерная (Lossless)"} - - {id: 373, cat: Audio, desc: " |- Камерная"} - - {id: 1258, cat: Audio/Lossless, desc: " |- Фортепиано (Lossless)"} - - {id: 372, cat: Audio, desc: " |- Фортепиано"} - - {id: 1160, cat: Audio/Lossless, desc: " |- В обработке/Classical Crossover/Neoclassical (Lossless)"} - - {id: 876, cat: Audio, desc: " |- В обработке/Classical Crossover/Neoclassical"} - - {id: 1255, cat: Audio/Lossless, desc: " |- Классика (сборники) (Lossless)"} - - {id: 376, cat: Audio, desc: " |- Классика (сборники)"} - - {id: 326, cat: Audio, desc: "Jazz, Blues, Soul"} - - {id: 1352, cat: Audio, desc: " |- Jazz (Hi-Res)"} - - {id: 359, cat: Audio/Lossless, desc: " |- Jazz (Lossless)"} - - {id: 358, cat: Audio, desc: " |- Jazz"} - - {id: 1353, cat: Audio, desc: " |- Blues, Soul (Hi-Res)"} - - {id: 1188, cat: Audio/Lossless, desc: " |- Blues, Soul (Lossless)"} - - {id: 1189, cat: Audio, desc: " |- Blues, Soul"} - - {id: 328, cat: Audio, desc: "Шансон, Авторская и Военная песня"} - - {id: 1370, cat: Audio, desc: " |- Шансон, Авторская и Военная песня (Hi-Res)"} - - {id: 1180, cat: Audio/Lossless, desc: " |- Зарубежный Шансон (Lossless)"} - - {id: 1181, cat: Audio, desc: " |- Зарубежный Шансон"} - - {id: 364, cat: Audio/Lossless, desc: " |- Русский Шансон (Lossless)"} - - {id: 363, cat: Audio, desc: " |- Русский Шансон"} - - {id: 1179, cat: Audio/Lossless, desc: " |- Авторская и Военная песня (Lossless)"} - - {id: 879, cat: Audio, desc: " |- Авторская и Военная песня"} - - {id: 322, cat: Audio, desc: "Rock, Alternative, Punk, Metal"} - - {id: 1350, cat: Audio, desc: " |- Rock (Hi-Res)"} - - {id: 962, cat: Audio/Lossless, desc: " |- Rock (Lossless)"} - - {id: 333, cat: Audio, desc: " |- Rock"} - - {id: 1356, cat: Audio, desc: " |- Alternative, Punk (Hi-Res)"} - - {id: 965, cat: Audio/Lossless, desc: " |- Alternative, Punk (Lossless)"} - - {id: 336, cat: Audio, desc: " |- Alternative, Punk"} - - {id: 1362, cat: Audio, desc: " |- Hard Rock (Hi Res)"} - - {id: 337, cat: Audio/Lossless, desc: " |- Hard Rock (Lossless)"} - - {id: 338, cat: Audio, desc: " |- Hard Rock"} - - {id: 1351, cat: Audio, desc: " |- Metal (Hi-Res)"} - - {id: 963, cat: Audio/Lossless, desc: " |- Metal (Lossless)"} - - {id: 334, cat: Audio, desc: " |- Metal"} - - {id: 1357, cat: Audio, desc: " |- Русский Рок (Hi-Res)"} - - {id: 961, cat: Audio/Lossless, desc: " |- Русский Рок (Lossless)"} - - {id: 332, cat: Audio, desc: " |- Русский рок"} - - {id: 325, cat: Audio, desc: "Pop"} - - {id: 1354, cat: Audio, desc: " |- Pop (Hi-Res)"} - - {id: 1165, cat: Audio/Lossless, desc: " |- Eurodance, Euro-House, Technopop (Lossless)"} - - {id: 1166, cat: Audio, desc: " |- Eurodance, Euro-House, Technopop"} - - {id: 1168, cat: Audio, desc: " |- Disco, Italo-Disco, Euro-Disco, Hi-NRG"} - - {id: 1167, cat: Audio/Lossless, desc: " |- Disco, Italo-Disco, Euro-Disco, Hi-NRG (Lossless)"} - - {id: 1162, cat: Audio/Lossless, desc: " |- Отечественная поп-музыка (Lossless)"} - - {id: 352, cat: Audio, desc: " |- Отечественная поп-музыка"} - - {id: 1164, cat: Audio/Lossless, desc: " |- Советская эстрада, Ретро (Lossless)"} - - {id: 1163, cat: Audio, desc: " |- Советская эстрада, Ретро"} - - {id: 1161, cat: Audio/Lossless, desc: " |- Зарубежная поп-музыка (Lossless)"} - - {id: 353, cat: Audio, desc: " |- Зарубежная поп-музыка"} - - {id: 324, cat: Audio, desc: "Electronic"} - - {id: 1327, cat: Audio/Lossless, desc: " |- Psybient, Psychill, Psydub (Lossless)"} - - {id: 1328, cat: Audio, desc: " |- Psybient, Psychill, Psydub"} - - {id: 1325, cat: Audio/Lossless, desc: " |- Downtempo, Trip-Hop, Lounge (Lossless)"} - - {id: 1326, cat: Audio, desc: " |- Downtempo, Trip-Hop, Lounge"} - - {id: 1365, cat: Audio, desc: " |- Downtempo, Ambient (Hi-Res)"} - - {id: 1366, cat: Audio, desc: " |- Experimental, Industrial (Hi-Res)"} - - {id: 1323, cat: Audio/Lossless, desc: " |- Ambient, Experimental, Modern Classical (Lossless)"} - - {id: 1324, cat: Audio, desc: " |- Ambient, Experimental, Modern Classical"} - - {id: 976, cat: Audio/Lossless, desc: " |- Trance (Lossless)"} - - {id: 346, cat: Audio, desc: " |- Trance"} - - {id: 1363, cat: Audio, desc: " |- Trance, House, Techno (Hi-Res)"} - - {id: 977, cat: Audio/Lossless, desc: " |- House, Techno, Electro, Minimal (Lossless)"} - - {id: 345, cat: Audio, desc: " |- House"} - - {id: 349, cat: Audio, desc: " |- Techno, Electro, Minimal"} - - {id: 1243, cat: Audio, desc: " |- Label-Packs"} - - {id: 347, cat: Audio, desc: " |- Easy listening"} - - {id: 979, cat: Audio/Lossless, desc: " |- Industrial, EBM, Dark Electro (Lossless)"} - - {id: 673, cat: Audio, desc: " |- Experimental Electronic"} - - {id: 671, cat: Audio, desc: " |- Industrial, EBM, Dark Electro"} - - {id: 1224, cat: Audio/Lossless, desc: " |- IDM (Lossless)"} - - {id: 1225, cat: Audio, desc: " |- IDM"} - - {id: 1367, cat: Audio, desc: " |- Synthpop, New Wave, Retro (Hi-Res)"} - - {id: 980, cat: Audio/Lossless, desc: " |- Synthpop, New Wave (Lossless)"} - - {id: 672, cat: Audio, desc: " |- Synthpop, New Wave"} - - {id: 1316, cat: Audio/Lossless, desc: " |- Dubstep, Future Garage, Bass Music, UK Garage (Lossless)"} - - {id: 1317, cat: Audio, desc: " |- Dubstep, Future Garage, Bass Music, UK Garage"} - - {id: 1364, cat: Audio, desc: " |- Drum'n'Bass, Breakbeat (Hi-Res)"} - - {id: 981, cat: Audio/Lossless, desc: " |- Drum'n'Bass, Jungle, Breaks, Breakbeat (Lossless)"} - - {id: 344, cat: Audio, desc: " |- Drum'n'Bass, Jungle, Breaks, Breakbeat"} - - {id: 1368, cat: Audio, desc: " |- Hardcore, Extreme (Hi-Res)"} - - {id: 983, cat: Audio/Lossless, desc: " |- Hardstyle, Jumpstyle, Hardcore (Lossless)"} - - {id: 984, cat: Audio, desc: " |- Hardstyle, Jumpstyle, Hardcore"} - - {id: 982, cat: Audio/Lossless, desc: " |- Psychedelic, psytrance, fullon (Lossless)"} - - {id: 348, cat: Audio, desc: " |- Psychedelic, psytrance, fullon"} - - {id: 674, cat: Audio, desc: " |- Radioshow, Live Mixes"} - - {id: 323, cat: Audio, desc: "Rap, Hip-hop, RnB, Reggae"} - - {id: 1187, cat: Audio/Lossless, desc: " |- Rap, Hip-hop зарубежный (Lossless)"} - - {id: 339, cat: Audio, desc: " |- Rap, Hip-hop зарубежный"} - - {id: 1186, cat: Audio/Lossless, desc: " |- Rap, Hip-hop отечественный (Lossless)"} - - {id: 340, cat: Audio, desc: " |- Rap, Hip-hop отечественный"} - - {id: 1185, cat: Audio/Lossless, desc: " |- RnB, Reggae (Lossless)"} - - {id: 341, cat: Audio, desc: " |- RnB, Reggae"} - - {id: 329, cat: Audio, desc: "East Asian Music"} - - {id: 1361, cat: Audio, desc: " |- Asian Music (Hi-Res)"} - - {id: 369, cat: Audio/Lossless, desc: " |- Asian Traditional, Ethnic (Lossless)"} - - {id: 368, cat: Audio, desc: " |- Asian Traditional, Ethnic"} - - {id: 1218, cat: Audio/Lossless, desc: " |- Asian Pop (Lossless)"} - - {id: 365, cat: Audio, desc: " |- Asian Pop"} - - {id: 1217, cat: Audio/Lossless, desc: " |- Asian Rock, Metal (Lossless)"} - - {id: 366, cat: Audio, desc: " |- Asian Rock, Metal"} - - {id: 1215, cat: Audio/Lossless, desc: " |- Doujin Music (Lossless)"} - - {id: 1216, cat: Audio, desc: " |- Doujin Music"} - - {id: 1213, cat: Audio/Lossless, desc: " |- Other Asian (Lossless)"} - - {id: 367, cat: Audio, desc: " |- Other Asian"} - - {id: 331, cat: Audio, desc: "Other Styles"} - - {id: 1358, cat: Audio, desc: " |- Instrumental (Hi-Res)"} - - {id: 1157, cat: Audio/Lossless, desc: " |- Instrumental (Lossless)"} - - {id: 711, cat: Audio, desc: " |- Instrumental"} - - {id: 1159, cat: Audio/Lossless, desc: " |- New Age/Meditative/Relax (Lossless)"} - - {id: 378, cat: Audio, desc: " |- New Age/Meditative/Relax"} - - {id: 1359, cat: Audio, desc: " |- Folk (Hi-Res)"} - - {id: 1158, cat: Audio/Lossless, desc: " |- Folk (Lossless)"} - - {id: 379, cat: Audio, desc: " |- Folk"} - - {id: 380, cat: Audio/Lossless, desc: " |- Other (Lossless)"} - - {id: 1178, cat: Audio, desc: " |- Other"} - - {id: 1360, cat: Audio, desc: " |- OST (Hi-Res)"} - - {id: 361, cat: Audio/Lossless, desc: " |- OST (Lossless)"} - - {id: 360, cat: Audio, desc: " |- OST"} - - {id: 327, cat: Audio, desc: "Неофициальные сборники"} - - {id: 1184, cat: Audio, desc: " |- Jazz, Blues, Soul (сборники)"} - - {id: 824, cat: Audio, desc: " |- Шансон, Авторская и Военная песня (сборники)"} - - {id: 1182, cat: Audio, desc: " |- Rock, Alternative, Punk, Metal (сборники)"} - - {id: 354, cat: Audio, desc: " |- Pop (сборники)"} - - {id: 877, cat: Audio, desc: " |- Electronic (сборники)"} - - {id: 1183, cat: Audio, desc: " |- Rap, Hip-hop, RnB, Reggae (сборники)"} - - {id: 1190, cat: Audio, desc: " |- Instrumental/New Age/Meditative/Relax (сборники)"} - - {id: 917, cat: Audio, desc: " |- Прочее (сборники)"} - # Other - - {id: 410, cat: Other, desc: "Win Игры"} - - {id: 411, cat: Other, desc: " |- Горячие новинки Игр"} - - {id: 412, cat: Other, desc: " |- Action (FPS)"} - - {id: 1008, cat: Other, desc: " |- Action (TPS)"} - - {id: 415, cat: Other, desc: " |- Adventure/Quest"} - - {id: 746, cat: Other, desc: " |- Arcade"} - - {id: 428, cat: Other, desc: " |- Online (MMO)"} - - {id: 1009, cat: Other, desc: " |- Online Action (MMO)"} - - {id: 413, cat: Other, desc: " |- RPG"} - - {id: 414, cat: Other, desc: " |- Strategy (RTS/TBS/Grand)"} - - {id: 1010, cat: Other, desc: " |- Strategy Tactical (RTS/TBS)"} - - {id: 1012, cat: Other, desc: " |- Strategy (Manage/Busin)"} - - {id: 1014, cat: Other, desc: " |- Racing"} - - {id: 416, cat: Other, desc: " |- Simulation (Flight/Space)"} - - {id: 1013, cat: Other, desc: " |- Simulation (Sport)"} - - {id: 1015, cat: Other, desc: " |- Simulation (Other)"} - - {id: 268, cat: Other, desc: " |- Action/Arcade/Platformer (Casual)"} - - {id: 1016, cat: Other, desc: " |- Adventure/Quest (Casual)"} - - {id: 1041, cat: Other, desc: " |- Classic Arcade/Zuma/3match (Casual)"} - - {id: 1018, cat: Other, desc: " |- Board/Puzzle/Logic (Casual)"} - - {id: 1017, cat: Other, desc: " |- Strategy/Manager/Business (Casual)"} - - {id: 972, cat: Other, desc: " |- AddOn/DLC/Mod для Игр"} - - {id: 971, cat: Other, desc: " |- Demo/Beta версии Игр"} - - {id: 970, cat: Other, desc: " |- Языковые пакеты для Игр"} - - {id: 969, cat: Other, desc: " |- Patch/Tweak/Trainer/Other для Игр"} - - {id: 968, cat: Other, desc: " |- NoCD/NoDVD/Crack для Игр"} - - {id: 1146, cat: Other, desc: " |- Песочница Win Игр"} - - {id: 418, cat: Other, desc: "Win Старые Игры"} - - {id: 1061, cat: Other, desc: " |- Action (FPS)"} - - {id: 1060, cat: Other, desc: " |- Action (TPS)"} - - {id: 1059, cat: Other, desc: " |- Adventure/Quest"} - - {id: 1058, cat: Other, desc: " |- Arcade"} - - {id: 1057, cat: Other, desc: " |- RPG"} - - {id: 1056, cat: Other, desc: " |- Strategy (RTS/TBS/Grand)"} - - {id: 1054, cat: Other, desc: " |- Strategy Tactical (RTS/TBS/Wargame)"} - - {id: 1053, cat: Other, desc: " |- Strategy (Manage/Busin)"} - - {id: 1052, cat: Other, desc: " |- Racing"} - - {id: 1051, cat: Other, desc: " |- Simulation (Flight/Space)"} - - {id: 1050, cat: Other, desc: " |- Simulation (Sport)"} - - {id: 1049, cat: Other, desc: " |- Simulation (Other)"} - - {id: 1048, cat: Other, desc: " |- AddOn/DLC/Mod для Игр"} - - {id: 1047, cat: Other, desc: " |- Demo/Beta версии Игр"} - - {id: 1046, cat: Other, desc: " |- Языковые пакеты для Игр"} - - {id: 1045, cat: Other, desc: " |- Patch/Tweak/Trainer/Other для Игр"} - - {id: 1044, cat: Other, desc: " |- NoCD/NoDVD/Crack для Игр"} - - {id: 382, cat: Other, desc: "Консольные Игры"} - - {id: 390, cat: Other, desc: " |- Тех. раздел Консолей"} - - {id: 387, cat: Other, desc: " |- Xbox 360"} - - {id: 388, cat: Other, desc: " |- Wii, GameCube"} - - {id: 1264, cat: Other, desc: " |- Wii U"} - - {id: 1318, cat: Other, desc: " |- Switch"} - - {id: 385, cat: Other, desc: " |- PS1"} - - {id: 386, cat: Other, desc: " |- PS2"} - - {id: 848, cat: Other, desc: " |- PS3"} - - {id: 1321, cat: Other, desc: " |- PS4"} - - {id: 383, cat: Other, desc: " |- PSP"} - - {id: 384, cat: Other, desc: " |- Psx to PSP"} - - {id: 1292, cat: Other, desc: " |- PS Vita"} - - {id: 389, cat: Other, desc: " |- Ромы"} - - {id: 391, cat: Other, desc: " |- Другие приставки"} - # pda and mobile - - {id: 1240, cat: Other, desc: " |- Всё для детей и родителей для Android"} - - {id: 830, cat: Other, desc: " |- ПО для Android"} - - {id: 833, cat: Other, desc: " |- Игры для Android"} - - {id: 839, cat: Other, desc: " |- Навигация для Android"} - - {id: 1233, cat: Other, desc: " |- Прошивки для Android устройств"} - - {id: 1236, cat: Other, desc: " |- Вспомогательное ПО для Android"} - - {id: 832, cat: Other, desc: " |- ПО и Игры на Java"} - - {id: 829, cat: Other, desc: " |- ПО и Игры для Symbian"} - - {id: 828, cat: Other, desc: " |- ПО и Игры для Windows Mobile"} - - {id: 1231, cat: Other, desc: " |- Навигация для др. мобильных устройств"} - - {id: 840, cat: Other, desc: " |- Прошивки для др. мобильных устройств"} - - {id: 1232, cat: Other, desc: " |- Вспомогательное ПО для др. мобильных устройств"} - - {id: 841, cat: Other, desc: " |- Прочее для мобильных устройств"} - - {id: 1238, cat: Other, desc: " |- Карты для навигационного ПО"} - - {id: 844, cat: Other, desc: " |- Мобильное Аудио"} - - {id: 842, cat: Other, desc: " |- Мобильное Видео"} - - {id: 843, cat: Other, desc: " |- Темы и Изображения"} - # apple - - {id: 537, cat: Other, desc: " |- macOS (Apple)"} - - {id: 538, cat: Other, desc: " |- macOS (osx86project/hackintosh)"} - - {id: 1151, cat: Other, desc: " |- Разное для macOS (Apple/hackintosh)"} - - {id: 1083, cat: Other, desc: " |- Графика для macOS"} - - {id: 1029, cat: Other, desc: " |- CAD, 3D, ПО для специалистов для macOS"} - - {id: 1082, cat: Other, desc: " |- Офис, Интернет для macOS"} - - {id: 1028, cat: Other, desc: " |- Аудио и видео редакторы для macOS"} - - {id: 1087, cat: Other, desc: " |- Плееры, конвертеры, кодеки для macOS"} - - {id: 1030, cat: Other, desc: " |- Утилиты для macOS"} - - {id: 1039, cat: Other, desc: " |- Kinder Games для macOS"} - - {id: 1038, cat: Other, desc: " |- Quests, Adventure, Arcade для macOS"} - - {id: 1037, cat: Other, desc: " |- Action, FPS для macOS"} - - {id: 1036, cat: Other, desc: " |- Strategy, RPG для macOS"} - - {id: 1035, cat: Other, desc: " |- Racing, Simulation, Sports для macOS"} - - {id: 1034, cat: Other, desc: " |- Casual Games, Other для macOS"} - - {id: 822, cat: Other, desc: " |- Тестовые macOS Игры"} - - {id: 1093, cat: Other, desc: " |- Прошивки iOS и AppleTV"} - - {id: 1092, cat: Other, desc: " |- UnLock, Jailbreak, Cydia"} - - {id: 1091, cat: Other, desc: " |- ПО для iOS"} - - {id: 834, cat: Other, desc: " |- Игры для iOS"} - - {id: 831, cat: Other, desc: " |- ПО из App Store"} - - {id: 1155, cat: Other, desc: " |- Отечественное видео для устройств Apple"} - - {id: 1156, cat: Other, desc: " |- Отечественное видео HD для устройств Apple"} - - {id: 1099, cat: Other, desc: " |- Зарубежное видео для устройств Apple"} - - {id: 1098, cat: Other, desc: " |- Зарубежное видео HD для устройств Apple"} - - {id: 1096, cat: Audio, desc: " |- Музыка (AAC)"} - - {id: 1097, cat: Audio/Lossless, desc: " |- Музыка Lossless (ALAC)"} - - {id: 1095, cat: Audio/Audiobook, desc: " |- Аудиокниги (AAC)"} - # linux - - {id: 536, cat: Other, desc: "Linux, Unix и другие ОС"} - - {id: 563, cat: Other, desc: " |- ОС Linux"} - - {id: 1032, cat: Other, desc: " |- ОС *Nix (Alpha, Beta, RC)"} - - {id: 1031, cat: Other, desc: " |- Сборки ОС Linux"} - - {id: 1025, cat: Other, desc: " |- ОС *BSD"} - - {id: 1026, cat: Other, desc: " |- Другие ОС"} - - {id: 564, cat: Other, desc: " |- *Nix. Программы"} - - {id: 1137, cat: Other, desc: " |- Специализированные ОС"} - - {id: 417, cat: Other, desc: "*Nix Игры"} - - {id: 1193, cat: Other, desc: " |- Native *Nix Games"} - - {id: 1192, cat: Other, desc: " |- Ported *Nix Games"} - # multimedia, designs, graphics - - {id: 1102, cat: Other, desc: "Материалы для мультимедиа и дизайна"} - - {id: 1070, cat: Other, desc: " |- Digital Juice"} - - {id: 534, cat: Other, desc: " |- Звуковые библиотеки"} - - {id: 676, cat: Other, desc: " |- Пресеты"} - - {id: 1077, cat: Other, desc: " |- Дополнения"} - - {id: 267, cat: Other, desc: " |- Футажи"} - - {id: 1071, cat: Other, desc: " |- 3D модели и материалы"} - - {id: 1134, cat: Other, desc: " |- Web-дизайн и шаблоны сайтов"} - - {id: 1107, cat: Other, desc: " |- Текстуры и Фоны"} - - {id: 1075, cat: Other, desc: " |- Шрифты"} - - {id: 1105, cat: Other, desc: " |- Шаблоны и Рамки"} - - {id: 676, cat: Other, desc: " |- Костюмы"} - - {id: 1072, cat: Other, desc: " |- Растровый клипарт (фото)"} - - {id: 166, cat: Other, desc: " |- Растровый клипарт (элементы)"} - - {id: 1078, cat: Other, desc: " |- Растровый клипарт (иллюстрации)"} - - {id: 1074, cat: Other, desc: " |- Векторный клипарт"} - - {id: 1076, cat: Other, desc: " |- Сборники"} - - {id: 1266, cat: Other, desc: "Проекты"} - - {id: 1267, cat: Other, desc: " |- Детские (проекты)"} - - {id: 1268, cat: Other, desc: " |- Свадебные и романтические (проекты)"} - - {id: 1269, cat: Other, desc: " |- Праздничные (проекты)"} - - {id: 1270, cat: Other, desc: " |- Презентации (проекты)"} - - {id: 1277, cat: Other, desc: " |- Трейлеры (проекты)"} - - {id: 1271, cat: Other, desc: " |- Спортивные (проекты)"} - - {id: 1272, cat: Other, desc: " |- Логотипы (проекты)"} - - {id: 1273, cat: Other, desc: " |- Слайдшоу (проекты)"} - - {id: 1274, cat: Other, desc: " |- Титры (проекты)"} - - {id: 1275, cat: Other, desc: " |- Элементы (проекты)"} - - {id: 1276, cat: Other, desc: " |- Разное (проекты)"} - - {id: 1103, cat: Other, desc: "Графика, Арт, Разное"} - - {id: 1114, cat: Other, desc: " |- Классическое искусство"} - - {id: 1113, cat: Other, desc: " |- Современное изобразительное искусство"} - - {id: 1115, cat: Other, desc: " |- Книжная иллюстрация"} - - {id: 1129, cat: Other, desc: " |- Современная фотография"} - - {id: 1111, cat: Other, desc: " |- Иконки и аватарки"} - - {id: 1116, cat: Other, desc: " |- Картинки"} - - {id: 808, cat: Other, desc: " |- Обои для рабочего стола"} - - {id: 1139, cat: Other, desc: " |- Обои для рабочего стола (16+)"} - - {id: 988, cat: Other, desc: " |- Графика (16+)"} - - {id: 1073, cat: Other, desc: " |- Интернет-творчество"} - # temp, archive - - {id: 892, cat: Other, desc: " |- Архив Мультфильмов"} - - {id: 91, cat: Other, desc: " |- Архив Видео. Кино, Театра"} - - {id: 150, cat: Other, desc: " |- Архив неактуальных Экранок и любого видео с Рекламой"} - - {id: 668, cat: Other, desc: " |- Архив Классики кино и Старых фильмов до 90-х"} - - {id: 1143, cat: Other, desc: " |- Архив Музыкального Видео"} - - {id: 802, cat: Other, desc: " |- Архив Сериалов и Архив Старого многосерийного кино до 90-х"} - - {id: 669, cat: Other, desc: " |- Архив Документалистики и TV"} - - {id: 400, cat: Other, desc: " |- Архив Юмора"} - - {id: 169, cat: Other, desc: " |- Архив Аниме"} - - {id: 94, cat: Other, desc: " |- Архив Книг и обучающих материалов"} - - {id: 303, cat: Other, desc: " |- Архив Авто"} - - {id: 92, cat: Other, desc: " |- Архив Музыки"} - - {id: 93, cat: Other, desc: " |- Архив Игр"} - - {id: 1333, cat: Other, desc: " |- Архив *Nix"} - - {id: 95, cat: Other, desc: " |- Архив Программ"} - - {id: 184, cat: Other, desc: " |- Архив КПК и Мобильных устройств"} - - {id: 1080, cat: Other, desc: " |- Архив 4Apple"} - - {id: 180, cat: Other, desc: " |- Архив Медиа-Диз-Графика"} - - {id: 145, cat: Other, desc: " |- Видео (temp)"} - - {id: 146, cat: Other, desc: " |- Аниме (temp)"} - - {id: 161, cat: Other, desc: " |- Книги (temp)"} - - {id: 147, cat: Other, desc: " |- Музыка (temp)"} - - {id: 148, cat: Other, desc: " |- Игры (temp)"} - - {id: 149, cat: Other, desc: " |- Программы (temp)"} - - {id: 183, cat: Other, desc: " |- КПК и Мобильные устройства (temp)"} - - {id: 1145, cat: Other, desc: " |- 4Apple (temp)"} - - {id: 171, cat: Other, desc: " |- Медиа-Диз-Графика (temp)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_row - type: info - label: Search settings - default: This Indexer requires specific columns to be active on your account search settings.
Login to the web site and ensure that only the Автор (Author) and Форум (Forum) checkboxes are ticked in the Показывать колонку (Show Columns) section of the Настройки поиска Torrent (Torrent Search Settings) page.
Having other columns active may prevent the Indexer processing/displaying results correctly. - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: forum/index.php - selector: table.menutable a.mainmenu:contains("Выход") - -search: - paths: - - path: forum/tracker.php - method: post - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tm: -1 - shf: 1 - sha: 1 - ta: -1 - sns: -1 - sds: "{{ if .Config.freeleech }}1{{ else }}-1{{ end }}" - nm: "{{ .Keywords }}" - submit: "Поиск" - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: table.forumline.tablesorter > tbody > tr:has(a[href^="viewtopic.php?t="]):has(a[href^="download.php?id="]) - - fields: - category_id: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - category: - text: "{{ .Result.category_id }}" - title: - selector: a[href^="viewtopic.php?t="] > b - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(?i)\\bКураж-Бамбей\\b", "kurazh"] - - name: re_replace - args: ["(?i)\\bКубик в Кубе\\b", "Kubik"] - - name: re_replace - args: ["(?i)\\bКравец\\b", "Kravec"] - - name: re_replace - args: ["(?i)\\bПифагор\\b", "Pifagor"] - - name: re_replace - args: ["(?i)\\bНевафильм\\b", "Nevafilm"] - - name: re_replace - args: ["(?i)\\bЛицензия\\b", "Lic"] - - name: re_replace - args: ["(?i)\\bселезень\\b", "selezen"] - - name: re_replace - args: ["(?i)\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if and (ne .Result.category_id \"913\") (.Config.addrussiantotitle) }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="viewtopic.php?t="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - grabs: - selector: td:nth-child(9) - date: - # unix - selector: td:last-child > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - downloadvolumefactor: - case: - img[src$="/gold.gif"]: 0 - img[src$="/silver.gif"]: 0.5 - img[src$="/bronze.gif"]: 0.75 - img[src$="/platinum.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="viewtopic.php?t="] > b -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordicbytes.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordicbytes.yml deleted file mode 100644 index d0f3cc5a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordicbytes.yml +++ /dev/null @@ -1,208 +0,0 @@ ---- -id: nordicbytes -replaces: - - danishbytes-api -name: NordicBytes -description: "NordicBytes is a Mixed NORDIC Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://nordicbytes.org/ -legacylinks: - - https://danishbytes.org/ - - https://danishbytes.art/ - - https://danishbytes.club/ - - https://danishbytes2.org/ - - https://dbytes.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: PC/0day, desc: "Apps"} - - {id: 4, cat: PC/Games, desc: "Games"} - - {id: 5, cat: Audio, desc: "Sound"} - - {id: 6, cat: Books, desc: "Books"} - - {id: 7, cat: Audio/Audiobook, desc: "AudioBooks"} - - {id: 8, cat: Audio, desc: "Podcasts"} - - {id: 9, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_apikey - type: info - label: About your API key - default: "Find or Generate a new API Key by accessing the NordicBytes Account API Key page." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your account is automatically disabled after 90 days of no activity." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://placehold.co/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - 0: "{{ .False }}" - 1: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 2 day (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json NBy v0.0.43 (based on UNIT3D 8.2.0) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordichd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordichd.yml deleted file mode 100644 index a9608fbf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordichd.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: nordichd -name: NORDiCHD -description: "NORDiCHD is a Mixed NORDIC Private Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://nordichd.org/ -legacylinks: - - https://nordichd.sytes.net/ - - http://nordichd.sytes.net/ - - http://nordichd.org/ - - http://cinefilhd.eu/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 14, cat: Movies/SD, desc: "Movies SD"} - - {id: 16, cat: Movies/SD, desc: "Movies Retro"} - - {id: 17, cat: Movies/HD, desc: "Movies HD"} - - {id: 31, cat: Movies/BluRay, desc: "Movies BDRip"} - - {id: 32, cat: Movies/BluRay, desc: "Movies BluRay"} - - {id: 33, cat: Movies, desc: "Movies Pack"} - - {id: 38, cat: Movies/UHD, desc: "Movies 4k/2160p"} - - {id: 6, cat: Movies, desc: "iNTERNAL"} - - {id: 18, cat: Movies/SD, desc: "iNTERNAL SD"} - - {id: 19, cat: Movies/SD, desc: "iNTERNAL Retro"} - - {id: 34, cat: Movies/HD, desc: "iNTERNAL HD"} - - {id: 35, cat: Movies/BluRay, desc: "iNTERNAL BDRip"} - - {id: 36, cat: Movies/BluRay, desc: "iNTERNAL BluRay"} - - {id: 37, cat: Movies, desc: "iNTERNAL Pack"} - - {id: 39, cat: Movies/UHD, desc: "iNTERNAL 4k/2160p"} - - {id: 2, cat: TV, desc: "TV Episodes"} - - {id: 3, cat: TV/SD, desc: "TV Episodes SD"} - - {id: 4, cat: TV, desc: "TV Episodes Series"} - - {id: 26, cat: TV/HD, desc: "TV Episodes HDTV Episodes"} - - {id: 27, cat: TV/HD, desc: "TV Episodes HDTV Series"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and check that you are not being forced to change your password because it has expired after 180 days." - -login: - path: ?p=home&pid=1 - method: form - form: form#sls_form - submitpath: ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: div.error - - selector: :contains("-ERROR-") - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - # 7 results per page, but fetching more pages when results < 7 returns duplicates from each additional page. - - path: / - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - # does not support imdbid search or have imdbid links in results. - error: - - selector: div.error:not(:contains("There are no results found.")) - - rows: - selector: "div.torrent-box[id^=\"torrent_\"]{{ if .Config.freeleech }}:has(img[src$=\"/torrent_free.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="?p=torrents&pid=10&cid="] - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - details: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download&tid="] - attribute: href - poster: - selector: a[href*="data/torrents/torrent_images/"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: div.torrentDetails > div:has(a[href*="&genre="]) - filters: - - name: replace - args: ["Genre:", ""] - description: - text: "{{ .Result.genre }}" - size: - selector: a[rel="torrent_size"] - grabs: - selector: a[rel="times_completed"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - date_day: - selector: div.torrentOwner:contains("day") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - selector: div.torrentOwner:contains("-") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - selector: div.torrentOwner abbr - optional: true - attribute: data-time - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img[src$="/torrent_free.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordicquality.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordicquality.yml deleted file mode 100644 index fbdc5bb0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nordicquality.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: nordicquality-api -name: NordicQuality (API) -description: "NordicQuality is a NORDIC Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://nordicq.org/ -legacylinks: - - https://nordicq.info/ # 404 Not Found nginx/1.18.0 (Ubuntu) - - https://nordicq.cc/ # 404 Not Found nginx/1.18.0 (Ubuntu) - -caps: - categorymappings: - - {id: 8, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 7, cat: Books, desc: "Books"} - - {id: 4, cat: Console, desc: "Games"} - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 9, cat: Audio/Other, desc: "Podcasts"} - - {id: 5, cat: PC, desc: "Software"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 12, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your NordicQuality account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your membership requires that you stay active at NordicQuality. To not get disabled, you are required to login to the site at least once every 90 days." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 3 days or 1:1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.1.1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nortorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nortorrent.yml deleted file mode 100644 index 9a95f928..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nortorrent.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: nortorrent -replaces: - - torrent9clone - - torrent9-tel -name: NorTorrent -description: "NorTorrent is a FRENCH Public tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: public -encoding: UTF-8 -# to fetch current domain use https://www.rantop.org/ -links: - - https://ww3-nortorrent.com/ -legacylinks: - # latest domains list - - https://www.rantop.org/ - - https://www.site-torrent.cc/ - # domain lists no longer working - - https://www.protege-torrent.com/ - - https://www.torrent.ws/ - - https://www.protege-liens.com/ - - https://www.protege-liens.net/ - - https://www.torrent.onl/ - - https://torrent9.news/ - - https://www.site-torrent.com/ - - https://www.rantop.my/ - - https://www.torrent-site.com/ - # actual legacylinks - - https://www.nortorrent3.com/ - - https://www.nortorrent4.com/ - - https://www.nortorrent5.com/ - - https://www.nortorrent6.com/ - - https://www.nortorrent.net/ - - https://www.nortorrent.town/ - - https://nortorrent-proxy.site/ - - https://ww1-nortorrent.me/ - - https://www.nortorrent.rent/ - - https://ww1-nortorrent.town/ - - https://www.nortorrent7.com/ - - https://www.nortorrent8.com/ - - https://ww1-nortorrent.com/ - - https://www-nortorrent.com/ - - https://ww2-nortorrent.com/ - -caps: - categorymappings: - - {id: Films, cat: Movies, desc: "Movies"} - - {id: Animes, cat: TV/Anime, desc: "Anime"} - - {id: Documentaires, cat: TV/Documentary, desc: "Documentaires"} - - {id: Séries, cat: TV, desc: "TV"} - - {id: Musiques, cat: Audio, desc: "Music"} - - {id: Ebooks, cat: Books, desc: "Books"} - - {id: Livres, cat: Books, desc: "Livres"} - - {id: Logiciels, cat: PC, desc: "Software"} - - {id: Applications, cat: PC, desc: "Applications"} - - {id: Jeux-PC, cat: PC/Games, desc: "PC Games"} - - {id: Jeux-Consoles, cat: Console/XBox 360, desc: "Console Games"} - - {id: Porno, cat: XXX, desc: "Porno"} - - {id: Spectacles, cat: TV/Other, desc: "Spectacles"} - - {id: Sports, cat: TV/Sport, desc: "Sports"} - - {id: Formations, cat: Other, desc: "Formations"} - - {id: Vidéos, cat: TV, desc: "Vidéos"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: info_flaresolverr - type: info_flaresolverr - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -download: - selectors: - - selector: a[href^="/get_torrents/"] - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}recherche/{{ .Keywords }}{{ else }}derniers{{ end }}" - keywordsfilters: - # if searching for season packs swith S01 to saison 1 #9712 - - name: re_replace - args: ["(?i)(S0)(\\d{1,2})$", "saison $2"] - - name: re_replace - args: ["(?i)(S)(\\d{1,3})$", "saison $2"] - - rows: - selector: table.table-striped > tbody > tr - - fields: - category: - selector: td:nth-child(1) i - attribute: class - title_phase1: - selector: td:nth-child(1) a - filters: - - name: re_replace - args: ["(?i)\\b(FRENCH|MULTI|TRUEFRENCH|VOSTFR|SUBFRENCH)\\b(.+?)(\\b((19|20)\\d{2})\\b)$", "$3 $1$2"] - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details_href: - selector: td:nth-child(1) a[href] - attribute: href - optional: true - filters: - - name: regexp - args: (/?detail/\d+) - details_onclick: - selector: td:nth-child(1) a[onclick] - attribute: onclick - optional: true - filters: - - name: regexp - args: (/?detail/\d+) - details: - text: "{{ or .Result.details_href .Result.details_onclick }}" - download: - text: "{{ .Result.details }}" - date: - text: now - size: - selector: td:nth-child(2) - seeders: - selector: td:nth-child(3) - optional: true - default: 0 - leechers: - selector: td:nth-child(4) - optional: true - default: 0 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nostradamus.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nostradamus.yml deleted file mode 100644 index 930e0770..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nostradamus.yml +++ /dev/null @@ -1,184 +0,0 @@ ---- -id: nostradamus -name: Nostradamus -description: "Nostradamus is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://nostradamus.foo/ - -caps: - categorymappings: - # from https://nostradamus.foo/api?t=caps&apikey=YOUR-API-KEY - - {id: 2000, cat: Movies, desc: "Films"} - - {id: 2020, cat: Movies, desc: "Films/Film"} - - {id: 2010, cat: Movies/Other, desc: "Films/Animation"} - - {id: 2030, cat: TV/Documentary, desc: "Films/Documentaire"} - - {id: 2040, cat: Audio/Video, desc: "Films/Spectacle"} - - {id: 2060, cat: Audio/Video, desc: "Films/Concert"} - - {id: 5000, cat: TV, desc: "Séries TV"} - - {id: 5040, cat: TV, desc: "Séries TV/Série TV"} - - {id: 5070, cat: TV/Anime, desc: "Séries TV/Animation Série"} - - {id: 5080, cat: TV, desc: "Séries TV/Emission TV"} - - {id: 5090, cat: Audio/Video, desc: "Vidéo"} - - {id: 5060, cat: TV/Sport, desc: "Vidéo/Sport"} - - {id: 5091, cat: Audio/Video, desc: "Vidéo/Vidéo-clips"} - - {id: 5092, cat: Audio/Video, desc: "Vidéo/Autre"} - - {id: 7000, cat: Books, desc: "eBook"} - - {id: 7010, cat: Audio/Audiobook, desc: "eBook/Audio"} - - {id: 7030, cat: Books/Comics, desc: "eBook/BDs"} - - {id: 7040, cat: Books/Comics, desc: "eBook/Comics"} - - {id: 7020, cat: Books/EBook, desc: "eBook/Livres"} - - {id: 7050, cat: Books/Comics, desc: "eBook/Mangas"} - - {id: 7060, cat: Books/Mags, desc: "eBook/Presse"} - - {id: 3000, cat: Audio, desc: "Audio"} - - {id: 3030, cat: Audio/Other, desc: "Audio/Karaoke"} - - {id: 3010, cat: Audio, desc: "Audio/Musique"} - - {id: 3040, cat: Audio/Other, desc: "Audio/Podcast Radio"} - - {id: 3050, cat: Audio/Other, desc: "Audio/Samples"} - - {id: 1000, cat: Console, desc: "Jeu vidéo"} - - {id: 1099, cat: Console/Other, desc: "Jeu vidéo/Autre"} - - {id: 1091, cat: PC/Games, desc: "Jeu vidéo/Linux"} - - {id: 1092, cat: PC/Games, desc: "Jeu vidéo/MacOS"} - - {id: 1040, cat: PC/Games, desc: "Jeu vidéo/Microsoft"} - - {id: 1030, cat: Console/NDS, desc: "Jeu vidéo/Nintendo"} - - {id: 1060, cat: Console/Other, desc: "Jeu vidéo/Smartphone"} - - {id: 1080, cat: Console/PS4, desc: "Jeu vidéo/Sony"} - - {id: 1061, cat: Console/Other, desc: "Jeu vidéo/Tablette"} - - {id: 1090, cat: PC/Games, desc: "Jeu vidéo/Windows"} - - modes: - search: [q] - tv-search: [q, season, ep, tvdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: API Key - - name: info_key - type: info - label: About your API Key - default: "You can find your API Key by accessing the Nostradamus API access page and scrolling down to the API Credentials section." - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -login: - # returns "401 Unauthorized, check your credentials" if passkey is invalid - path: api - method: get - inputs: - apikey: "{{ .Config.apikey }}" - t: search - limit: 1 - -search: - paths: - - path: api - response: - type: xml - - inputs: - apikey: "{{ .Config.apikey }}" - t: "{{ if .Query.IMDBID }}movie{{ else }}{{ end }}{{ if .Query.TVDBID }}tvsearch{{ else }}{{ end }}{{ if or .Query.TVDBID .Query.IMDBID }}{{ else }}search{{ end }}" - q: "{{ .Keywords }}" - cat: "{{ join .Categories \",\" }}" - season: "{{ .Query.Season }}" - ep: "{{ .Query.Ep }}" - imdbid: "{{ .Query.IMDBID }}" - tvdbid: "{{ .Query.TVDBID }}" - limit: 100 - - keywordsfilters: - # replace spaces between words with percent wildcard - - name: re_replace - args: ["\\W+", "%"] - - rows: - selector: rss > channel > item - - fields: - category: - selector: '[name="category"]' - attribute: value - title_phase1: - selector: title - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR|VF2|VFF|VFQ|VOQ|VFI|VOF)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: link - download: - selector: enclosure - attribute: url - infohash: - selector: '[name="infohash"]' - attribute: value - date: - # Thu, 15 Jan 2026 16:24:34 +0000 - selector: pubDate - filters: - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - size: - selector: '[name="size"]' - attribute: value - imdbid: - selector: '[name="imdbid"]' - attribute: value - tmdbid: - selector: '[name="tmdbid"]' - attribute: value - seeders: - selector: '[name="seeders"]' - attribute: value - leechers: - selector: '[name="leechers"]' - attribute: value - grabs: - selector: '[name="grabs"]' - attribute: value - downloadvolumefactor: - selector: '[name="downloadvolumefactor"]' - attribute: value - uploadvolumefactor: - selector: '[name="uploadvolumefactor"]' - attribute: value -# global MR is 0.5 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.5 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/novahd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/novahd.yml deleted file mode 100644 index ffacc8b8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/novahd.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: novahd -name: NovaHD -description: "NovaHD is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.novahd.top/ - - https://tracker.novahd.top/ - -caps: - categorymappings: - - {id: 413, cat: TV/Anime, desc: "Anime Series/番剧"} - - {id: 412, cat: TV/Anime, desc: "Anime/动漫"} - - {id: 411, cat: TV, desc: "Playlet/短剧"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentary/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动画"} - - {id: 406, cat: Audio/Video, desc: "MV/演唱会"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 409, cat: Audio, desc: "Music/音乐"} - - {id: 410, cat: Other/Misc, desc: "Misc/其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.9 2025-10-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nyaasi.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nyaasi.yml deleted file mode 100644 index 7a046234..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/nyaasi.yml +++ /dev/null @@ -1,300 +0,0 @@ ---- -id: nyaasi -name: Nyaa.si -description: "Nyaa is a Public torrent site focused on Eastern ASIAN media including anime, manga, literature and music" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://nyaa.si/ - - https://nyaa.iss.ink/ - - https://nyaa.land/ - - https://nyaa.mom/ - - https://nyaa.unblockninja.com/ # for magnets only -legacylinks: - - https://nyaa.black-mirror.xyz/ - - https://nyaa.unblocked.casa/ - - https://nyaa.proxyportal.fun/ - - https://nyaa.uk-unblock.xyz/ - - https://nyaa.ind-unblock.xyz/ - - https://nyaa.unblocked.bar/ - - https://nyaa.proxyportal.pw/ - - https://nyaa.uk-unblock.pro/ - - https://nyaa.root.yt/ - - https://nyaa.lol/ # dropped at request of owner - - https://nyaa.mrunblock.bond/ # for magnets only - - https://nyaa.nocensor.cloud/ - -caps: - categorymappings: - - {id: 1_0, cat: TV/Anime, desc: "Anime"} - - {id: 1_1, cat: TV/Anime, desc: "Anime - Anime Music Video"} - - {id: 1_2, cat: TV/Anime, desc: "Anime - English-translated"} - - {id: 1_3, cat: TV/Anime, desc: "Anime - Non-English-translated"} - - {id: 1_4, cat: TV/Anime, desc: "Anime - Raw"} - # Anime as Movies (Radarr uses t=movie): - - {id: 1_0, cat: Movies/Other, desc: "Anime"} - - {id: 1_1, cat: Movies/Other, desc: "Anime - Anime Music Video"} - - {id: 1_2, cat: Movies/Other, desc: "Anime - English-translated"} - - {id: 1_3, cat: Movies/Other, desc: "Anime - Non-English-translated"} - - {id: 1_4, cat: Movies/Other, desc: "Anime - Raw"} - - {id: 2_0, cat: Audio, desc: "Audio"} - - {id: 2_1, cat: Audio, desc: "Audio - Lossless"} - - {id: 2_2, cat: Audio, desc: "Audio - Lossy"} - - {id: 3_0, cat: Books, desc: "Literature"} - - {id: 3_1, cat: Books, desc: "Literature English-translated"} - - {id: 3_2, cat: Books, desc: "Literature - Non-English-translated"} - - {id: 3_3, cat: Books, desc: "Literature - Raw"} - - {id: 4_0, cat: TV, desc: "Live Action"} - - {id: 4_1, cat: TV, desc: "Live Action - English-translated"} - - {id: 4_2, cat: TV, desc: "Live Action - Idol/Promotional Video"} - - {id: 4_3, cat: TV, desc: "Live Action - Non-English-translated"} - - {id: 4_4, cat: TV, desc: "Live Action - Raw"} - - {id: 5_0, cat: Other, desc: "Pictures"} - - {id: 5_1, cat: Other, desc: "Pictures - Graphics"} - - {id: 5_2, cat: Other, desc: "Pictures - Photos"} - - {id: 6_0, cat: PC, desc: "Software"} - - {id: 6_1, cat: PC/ISO, desc: "Software - Applications"} - - {id: 6_2, cat: PC/Games, desc: "Software - Games"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: prefer_magnet_links - type: checkbox - label: Prefer Magnet Links - default: true - - name: sonarr_compatibility - type: checkbox - label: Improve Sonarr compatibility by trying to add Season information into Release Titles - default: false - - name: strip_s01 - type: checkbox - label: Remove first season keywords (S1/S01/Season 1), as some results do not include this for first/single season releases - default: false - - name: radarr_compatibility - type: checkbox - label: Improve Radarr compatibility by removing year information from keywords and adding it to Release Titles - default: false - - name: filter-id - type: select - label: Filter - default: 0 - options: - 0: No filter - 1: No remakes - 2: Trusted only - - name: cat-id - type: select - label: Category - default: 0_0 - options: - 0_0: "All categories" - 1_0: "Anime" - 1_1: "Anime - Anime Music Video" - 1_2: "Anime - English-translated" - 1_3: "Anime - Non-English-translated" - 1_4: "Anime - Raw" - 2_0: "Audio" - 2_1: "Audio - Lossless" - 2_2: "Audio - Lossy" - 3_0: "Literature" - 3_1: "Literature - English-translated" - 3_2: "Literature - Non-English-translated" - 3_3: "Literature - Lossy" - 4_0: "Live Action" - 4_1: "Live Action - English" - 4_2: "Live Action - Idol/PV" - 4_3: "Live Action - Non-English" - 4_4: "Live Action - Raw" - 5_0: "Pictures" - 5_1: "Pictures - Graphics" - 5_2: "Pictures - Photos" - 6_0: "Software" - 6_1: "Software - Applications" - 6_2: "Software - Games" - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -search: - paths: - - path: / - inputs: - q: "{{ .Keywords }}" - # strip 0 from start of episode number - #11019, or fetch page 2 for keywordless - - path: / - inputs: - q: "{{ if .Keywords }}{{ re_replace .Keywords \"\\b0(\\d{1})\\b\" \"$1\" }}{{ else }}{{ end }}" - p: "{{ if .Keywords }}{{ else }}2{{ end }}" - inputs: - # 0 all, 1 no remakes, 2 trusted only - f: "{{ .Config.filter-id }}" - c: "{{ .Config.cat-id }}" - s: "{{ .Config.sort }}" - o: "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: [" *\\b((?:19|20)\\d{2})\\b", "{{ if .Config.radarr_compatibility }}{{ else }} $1{{ end }}"] - - name: re_replace - args: ["(?i) *\\b(S(?:0|eason *)?1)\\b", "{{ if .Config.strip_s01 }}{{ else }} $1{{ end }}"] - - rows: - selector: tr.default,tr.danger,tr.success - - fields: - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: split - args: ["=", -1] - category_group_id: - selector: td:nth-child(1) a - attribute: href - filters: - - name: split - args: ["=", -1] - - name: split - args: ["_", 0] - title_default: - selector: td:nth-child(2) a:last-of-type - title_phase1: - selector: td:nth-child(2) a:last-of-type:contains("[PuyaSubs!] ") - optional: true - filters: - - name: append - args: " Spanish" - title_keyword_year: - text: "{{ .Query.Keywords }}" - filters: - - name: regexp - args: "\\b((19|20)\\d{2})\\b" - title_phase2: - text: "{{ or (.Result.title_phase1) (.Result.title_default) }}" - filters: - - name: re_replace - args: ["^(\\[.+?\\] ?)?(\\[.+?\\] ?)?(.+?)(\\[)", "$1$2$3{{ if and (.Config.radarr_compatibility) (.Result.title_keyword_year) }} {{ .Result.title_keyword_year }} $4{{ else }}$4{{ end }}"] - title_phase3: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b((?:S|Seasons?|EP?|Episodes?)\\s?)(\\d+)(?:\\-|[\\s~\\+àa&]+)(\\d+)\\b", "$1$2-$3"] - - name: re_replace - args: ["(?i)\\b(?:S|Seasons?)\\s?(\\d+(?:-\\d+)?)[\\s\\-]+(?:EP|Episodes?)\\s?(\\d+(?:-\\d+)?)\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:S|Seasons?)\\s?(\\d+(?:-\\d+)?)(?:.+?)(?:EP|Episodes?)\\s?(\\d+(?:-\\d+)?)\\b(?!(?:-\\d+)?\\sS\\d+(?:-\\d+)?E\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["\\b (II) - (\\d+)[\\s\\-~\\+àa&]+(\\d+)", " $1 S02 - $2-$3"] - - name: re_replace - args: ["\\b (II) - (\\d+)", " $1 S02 - $2"] - - name: re_replace - args: ["\\b (III) - (\\d+)[\\s\\-~\\+àa&]+(\\d+)", " $1 S03 - $2-$3"] - - name: re_replace - args: ["\\b (III) - (\\d+)", " $1 S03 - $2"] - - name: re_replace - args: ["(?i)\\b(\\d+)(st|nd|rd|th) Season\\b", "$0 S$1"] - - name: re_replace - args: ["(?i)_(\\d+)(st|nd|rd|th)_season_(\\d+)_", "$0S$1E$3_"] - - name: re_replace - args: ["(?i)_(\\d+)(st|nd|rd|th)_season_", "$0S$1_"] - - name: re_replace - args: ["(?i)\\b(S\\d+(?:-\\d+)?) - (\\d+)[\\s\\-~\\+àa&]+(\\d+)\\b", "$1E$2-$3"] - - name: re_replace - args: ["(?i)\\b(S\\d+(?:-\\d+)?) - (\\d+)\\b", "$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:S\\s|Seasons?\\s?)(\\d+(?:-\\d+)?)\\b(?!(?:-\\d+)?\\s(?:EP|Episodes?)?\\s?(?:\\d+(?:-\\d+)?)?\\s?S\\d+(?:E\\d+(?:-\\d+)?)?)", "S$1"] - - name: re_replace - args: ["(?i)\\b(?:EP|Episodes?)\\s?(\\d+(?:-\\d+)?)\\b(?!(?:-\\d+)?\\sS\\d+(?:-\\d+)?(?:E\\d+(?:-\\d+)?)?)", "S01E$1"] - - name: re_replace - args: ["\\s+", " "] - - name: trim - title_has_season: - text: "{{ .Result.title_phase3 }}" - filters: - - name: regexp - args: "(?i)(S\\d{1,3}(E\\d+)?)" - - name: append - args: "NULL" - title_has_episode: - text: "{{ .Result.title_phase3 }}" - filters: - - name: regexp - args: "\\b(.+? - ?)(\\d+(-\\d+)?) ([\\[\\(])\\b" - - name: append - args: "NULL" - title_has_movie_ova: - text: "{{ .Result.title_phase3 }}" - filters: - - name: regexp - args: "(?i)(?Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  • Ultimate User and above will keep the account forever
  • Veteran User level accounts will not be disabled if they are parked (control panel)
  • Unparked accounts will be banned if they have not logged in for 60 days
  • Users below Veteran User level will be banned if they have not logged in for more than 300 days
  • Users who have not generated any traffic in 30 days (i.e. upload/download are 0) will be banned
  • Banned accounts will be deleted if they have not logged in for more than 365 days
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 403, 404, 407, 436, 405, 434, 432, 413, 431, 409] - - path: special.php - categories: [411, 410, 415, 406, 437] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (4 does not appear to work) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.13 2024-09-21 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oldgreektracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oldgreektracker.yml deleted file mode 100644 index e0a6fa7e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oldgreektracker.yml +++ /dev/null @@ -1,230 +0,0 @@ ---- -id: oldgreektracker -name: Old Greek Tracker (OGT) -description: "Old Greek Tracker (OGT) is a GREEK Private Torrent Tracker for PRE-2020 MOVIES / TV / GENERAL" -language: el-GR -type: private -encoding: UTF-8 -links: - - https://oldgreektracker.xyz/ -legacylinks: - - http://oldgreektracker.xyz/ - -caps: - categorymappings: - # Apps - - {id: 4, cat: PC/0day, desc: "Apps: Windows"} - - {id: 5, cat: PC/0day, desc: "Apps: Linux"} - - {id: 6, cat: PC/Mac, desc: "Apps: MacOS"} - - {id: 7, cat: PC/Mobile-Android, desc: "Apps: Android"} - # Music - - {id: 9, cat: Audio/MP3, desc: "Music: Greek MP3"} - - {id: 10, cat: Audio/Lossless, desc: "Music: Greek FLAC"} - - {id: 11, cat: Audio/MP3, desc: "Music: Foreign MP3"} - - {id: 12, cat: Audio/Lossless, desc: "Music: Foreign FLAC"} - - {id: 68, cat: Audio, desc: "Music:Discographies"} - - {id: 71, cat: Audio, desc: "Music: Collections"} - - {id: 72, cat: Audio/Other, desc: "Music: Foreign Soundtracks"} - - {id: 86, cat: Audio, desc: "Music: Greek Discographies"} - - {id: 87, cat: Audio, desc: "Music: Greek Collections"} - - {id: 92, cat: Audio/Other, desc: "Music: Greek Soundtracks"} - - {id: 115, cat: Audio/Video, desc: "Music: Foreign Music Videos"} - - {id: 116, cat: Audio/Video, desc: "Music: Greek Music Videos"} - # Movies - - {id: 16, cat: Movies/SD, desc: "Movies: Foreign Movies Non HD"} - - {id: 20, cat: Movies/SD, desc: "Movies: Filmographies"} - - {id: 105, cat: Movies, desc: "Movies: Animation"} - - {id: 106, cat: Movies, desc: "Movies: Cartoons"} - - {id: 112, cat: Movies/HD, desc: "Movies: Movie Packs"} - - {id: 119, cat: Movies/SD, desc: "Movies: Greek Movies Non HD"} - - {id: 122, cat: Movies/HD, desc: "Movies: Foreign HDRip / BRRip"} - - {id: 129, cat: Movies/SD, desc: "Movies: Greek VHS"} - - {id: 130, cat: Movies/SD, desc: "Movies: Foreign VHS"} - - {id: 132, cat: Movies/SD, desc: "Movies: CAM / TS / TC"} - - {id: 133, cat: Movies/DVD, desc: "Movies: Foreign DVD5/DVD9"} - - {id: 134, cat: Movies/DVD, desc: "Movies: Greek DVD5/DVD9"} - - {id: 136, cat: Movies/DVD, desc: "Movies: Greek DVDRip"} - - {id: 138, cat: Movies/DVD, desc: "Movies: Foreign DVDRip"} - - {id: 139, cat: Movies/HD, desc: "Movies: Greek HDRip / BRRip"} - - {id: 140, cat: Movies, desc: "Movies: Greek Audio Cartoons"} - - {id: 141, cat: Movies, desc: "Movies: Greek Audio Animation"} - - {id: 145, cat: Movies/WEB-DL, desc: "Movies: Foreign WEBRip"} - - {id: 146, cat: Movies/WEB-DL, desc: "Movies: Greek WEBRip"} - # Books - - {id: 23, cat: Books/EBook, desc: "Books: Foreign Books"} - - {id: 24, cat: Books/EBook, desc: "Books: Greek Books"} - - {id: 27, cat: Books/Comics, desc: "Books: Comics"} - - {id: 69, cat: Audio/Audiobook, desc: "Books: Greek Audio Books"} - - {id: 114, cat: Audio/Audiobook, desc: "Books: Foreign Audio Books"} - # TV - - {id: 29, cat: TV/Anime, desc: "TV: Foreign Animation"} - - {id: 31, cat: TV/Other, desc: "TV: Foreign Cartoons"} - - {id: 35, cat: TV, desc: "TV: Greek TV Series"} - - {id: 36, cat: TV, desc: "TV: Greek TV Packs"} - - {id: 40, cat: TV, desc: "TV: Greek TV Shows"} - - {id: 67, cat: TV/Sport, desc: "TV: Sports"} - - {id: 75, cat: TV, desc: "TV: Foreign TV Series"} - - {id: 76, cat: TV, desc: "TV: Foreign TV Shows"} - - {id: 77, cat: TV, desc: "TV: Foreign TV Episodes"} - - {id: 79, cat: TV/Documentary, desc: "TV: Foreign Documentaries"} - - {id: 127, cat: TV/Other, desc: "TV: Tutorials"} - - {id: 142, cat: TV/Other, desc: "TV: Greek Audio Cartoons"} - - {id: 143, cat: TV/Anime, desc: "TV: Greek Audio Animation"} - - {id: 144, cat: TV/Documentary, desc: "TV: Greek Audio Documentaries"} - - {id: 147, cat: TV, desc: "TV: Foreign Packs"} - # Movies HD - - {id: 42, cat: Movies/HD, desc: "Movies HD: Remux / 1080p"} - - {id: 43, cat: Movies/WEB-DL, desc: "Movies HD: WEB-DL / 720p"} - - {id: 45, cat: Movies/BluRay, desc: "Movies HD: BluRay Disk / 1080p"} - - {id: 46, cat: Movies/UHD, desc: "Movies HD: Remux / 4K (UHD)"} - - {id: 47, cat: Movies/WEB-DL, desc: "Movies HD: WEB-DL / 1080p"} - - {id: 48, cat: Movies/UHD, desc: "Movies HD: WEB-DL / 4K (UHD)"} - - {id: 49, cat: Movies/HD, desc: "Movies HD: BluRay / 720p"} - - {id: 50, cat: Movies/HD, desc: "Movies HD: BluRay / 1080p"} - - {id: 51, cat: Movies/UHD, desc: "Movies HD: BluRay / 4K (UHD)"} - - {id: 66, cat: Movies/BluRay, desc: "Movies HD: BluRay Disk / 4K (UHD)"} - # Games - - {id: 74, cat: PC/Games, desc: "Games: PC Games"} - # the following are not selectable from the site's category pull down but exist as categories in results - - {id: 3, cat: PC, desc: "Apps"} - - modes: - search: [q] - tv-search: [q] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. The Default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "To keep records updated regularly, all inactive accounts will be deleted after 60 days of inactivity." - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: tr td span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://oldgreektracker.xyz/index.php?page=torrents&search=&category=0&active=0 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ range .Categories }}{{.}};{{end}}" - # 0 filename, 1 file&descr, 2 descr, 3 uploaders, 18 genre, 4 imdb, 5 gold, 6 silver, 7 bronze - options: "{{ if .Config.freeleech }}5{{ else }}0{{ end }}" - # 0 all, 1 active only, 2 dead only - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: table.lista > tbody > tr:has(a[href^="download.php?id="]):has(strong img) - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="index.php?page=torrent-details&id="] - details: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: onmouseover - filters: - - name: regexp - args: src=(.+?) width - - name: replace - args: ["torrentimg/nocover.jpg", ""] - size: - selector: td:nth-child(12) - date: - # auto adjusted by site account profile - selector: td:nth-child(8) - filters: - - name: dateparse - args: "dd/MM/yyyy" - seeders: - selector: td:nth-child(8) - optional: true - default: 0 - leechers: - optional: true - selector: td:nth-child(10) - default: 0 - grabs: - optional: true - selector: td:nth-child(11) - default: 0 - description: - selector: td:nth-child(1) - case: - img[src="/images/mod/ok.png"]: Verified - "*": Not Verified - downloadvolumefactor: - case: - img[src="images/freeleech.gif"]: 0 - img[src="images/gold.gif"]: 0 - img[src="images/silver.gif"]: 0.5 - img[src="images/bronze.gif"]: 0.75 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# xbtitFM v4.2.12 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oldtoonsworld.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oldtoonsworld.yml deleted file mode 100644 index 6846fc85..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oldtoonsworld.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -id: oldtoonsworld -name: OldToonsWorld (API) -description: "Old Toons World (OTW) is a Private Torrent Tracker for ANIMATED MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://oldtoons.world/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Books, desc: "Books"} - - {id: 4, cat: Audio, desc: "Audio"} - - {id: 5, cat: PC/Games, desc: "Games"} - - {id: 6, cat: TV, desc: "WOC"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Oldtoons account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "
  • You must log in once every 60 days or seed at least 1 torrent to not get disabled
  • Once disabled, you have another 60 days to log back in and have your account re-activated. You may need to contact Helpdesk to do so
  • Disabled account will be deleted after 180 days of inactivity
" - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/onejav.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/onejav.yml deleted file mode 100644 index 72f8c9e2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/onejav.yml +++ /dev/null @@ -1,80 +0,0 @@ ---- -id: onejav -name: OneJAV -description: "OneJAV is a Public tracker for ASIAN 3X (JAV)" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://onejav.com/ - -caps: - categories: - XXX: XXX - - modes: - search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -search: - paths: - - path: "{{ if .Keywords }}search/{{ .Keywords }}{{ else }}new{{ end }}" - - path: "{{ if .Keywords }}search/{{ .Keywords }}{{ else }}new{{ end }}?page=2" - - rows: - selector: div.mb-3 - - fields: - category: - text: XXX - title: - selector: a[href^="/torrent/"] - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href*="/download/"] - attribute: href - poster: - selector: img - attribute: src - _actress: - selector: a[href^="/actress/"] - optional: true - filters: - - name: prepend - args: "Actress: " - _tags: - selector: div.tags - optional: true - filters: - - name: prepend - args: "Tags: " - description_text: - selector: p.level - optional: true - description: - text: "{{ .Result.description_text }}
{{ .Result._actress }}
{{ .Result._tags }}" - date: - selector: p.is-6 > a - attribute: href - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "/yyyy/MM/dd zzz" - size: - selector: span.is-size-6 - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/onlyencodes-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/onlyencodes-api.yml deleted file mode 100644 index 41f06796..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/onlyencodes-api.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: onlyencodes-api -name: OnlyEncodes+ (API) -description: "OnlyEncodes+ is a Private Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://onlyencodes.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 2, cat: TV, desc: "TV Show"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your OnlyEncodes+ account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in once every 60 days or seed at least 1 torrent to not get disabled." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 8.3.6 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/opencd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/opencd.yml deleted file mode 100644 index 6661900f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/opencd.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: opencd -name: OpenCD -description: "OpenCD is a CHINESE Private Torrent Tracker for LOSSLESS ASIAN MUSIC" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://open.cd/ - -caps: - categorymappings: - - {id: 2, cat: Audio, desc: "华语流行(Pop)"} - - {id: 3, cat: Audio, desc: "古典音乐(Classical)"} - - {id: 11, cat: Audio, desc: "民族音乐(Instrumental)"} - - {id: 4, cat: Audio, desc: "原声配乐(OST)"} - - {id: 5, cat: Audio, desc: "泛摇滚乐(Rock)"} - - {id: 8, cat: Audio, desc: "爵士乡村(Jazz)"} - - {id: 12, cat: Audio, desc: "新世纪(NewAge)"} - - {id: 13, cat: Audio, desc: "舞曲(Dance)"} - - {id: 14, cat: Audio, desc: "电子(Electronic)"} - - {id: 15, cat: Audio, desc: "民谣(Folk)"} - - {id: 16, cat: Audio, desc: "独立(Indie)"} - - {id: 17, cat: Audio, desc: "嘻哈(Hip Hop)"} - - {id: 18, cat: Audio, desc: "音乐剧(Musical)"} - - {id: 19, cat: Audio, desc: "乡村(Country)"} - - {id: 20, cat: Audio, desc: "另类(Alternative)"} - - {id: 21, cat: Audio, desc: "世界音樂(World))"} - - {id: 9, cat: Audio, desc: "其它类型(Others)"} - - modes: - search: [q] - music-search: [q, artist] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. Default is 50. - -login: - # switched from the form method to the cookie method due to https://github.com/Prowlarr/Prowlarr/issues/792 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://open.cd/torrents.php?source11=1&source18=1&incldead=0&spstate=0&search=&search_area=0&search_mode=0 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}source{{.}}=1&{{end}}" - search: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl, 5 traklist, 6 artistname - search_area: "{{ if .Query.Artist }}6{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: td:nth-child(1) - attribute: title - case: - td[title="华语流行"]: 2 - td[title="古典音乐"]: 3 - td[title="民族音乐"]: 11 - td[title="原声配乐"]: 4 - td[title="泛摇滚乐"]: 5 - td[title="爵士乡村"]: 8 - td[title="新世纪"]: 12 - td[title="舞曲"]: 13 - td[title="电子"]: 14 - td[title="民谣"]: 15 - td[title="独立"]: 16 - td[title="嘻哈"]: 17 - td[title="音乐剧"]: 18 - td[title="乡村(Country)"]: 19 - td[title="另类(Alternative)"]: 20 - td[title="世界音樂(World)"]: 21 - td[title="其它类型"]: 9 - # Some torrents have no title set on td - td:not([title]): 9 - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[id^="attach"] - attribute: src - filters: - - name: prepend - args: "https://images.weserv.nl/?url={{ .Config.sitelink }}" # for display on dashboard - - name: append - args: "&w=180&h=270" # for display on dashboard - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(6) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(6):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - grabs: - selector: td:nth-child(10) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 36 hours (as seconds = 36 x 60 x 60) - text: 129600 - description: - selector: td:nth-child(3) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 (customised) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/opensharing.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/opensharing.yml deleted file mode 100644 index ca8e62e0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/opensharing.yml +++ /dev/null @@ -1,217 +0,0 @@ ---- -id: opensharing -name: OpenSharing -description: "OpenSharing is a RUSSIAN Public Torrent Tracker for 3X" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://opensharing.org/ - -caps: - categorymappings: - - {id: 6, cat: XXX, desc: "Полнометражные XXX фильмы / Full Length Movies"} - - {id: 7, cat: XXX, desc: " Классика / Classic (Полнометражные фильмы снятые до 2000 года)"} - - {id: 8, cat: XXX, desc: " Фильмы с сюжетом / Feature Vignettes"} - - {id: 9, cat: XXX, desc: " Порно-пародия / Parody"} - - {id: 11, cat: XXX, desc: " Компиляции и фильмы без сюжета / Movies without a story"} - - {id: 36, cat: XXX, desc: " Фильмы с Русским переводом"} - - {id: 37, cat: XXX, desc: " Эротические фильмы / Erotic Movies"} - - {id: 38, cat: XXX, desc: " Документальные фильмы 18+ / Documentary 18+"} - - {id: 39, cat: XXX, desc: " Паки полнометражных фильмов / Full Length Movies Packs"} - - {id: 12, cat: XXX, desc: "Русское порно / Russian - Фильмы и ролики"} - - {id: 13, cat: XXX, desc: " Порнофильмы с Русскими актрисами / Movies with Russian actresses"} - - {id: 14, cat: XXX, desc: " Сцены и ролики с Русскими актрисами / Russian Movie Scenes & SiteRip's"} - - {id: 15, cat: XXX, desc: " Паки видео с Русскими актрисами / Russian Video Packs"} - - {id: 28, cat: XXX, desc: "Зарубежные ролики по жанрам / Foreign videos by genre"} - - {id: 27, cat: XXX, desc: " Сцены из фильмов / Movie Scenes"} - - {id: 26, cat: XXX, desc: " Порно-кастинги / Casting"} - - {id: 19, cat: XXX, desc: " Family Roleplay, Incest & Taboo"} - - {id: 21, cat: XXX, desc: " Public & Reality, Sex In Car, Outdoors"} - - {id: 20, cat: XXX, desc: " Анал и ДП / Anal & DP"} - - {id: 29, cat: XXX, desc: " Секс втроём / Threesomes"} - - {id: 30, cat: XXX, desc: " Группавуха / Group & Gangbang"} - - {id: 35, cat: XXX, desc: " Молоденькие / Legal Teens"} - - {id: 32, cat: XXX, desc: " Мамочки / MILF & Mature"} - - {id: 33, cat: XXX, desc: " Бабушки / Grannies"} - - {id: 40, cat: XXX, desc: " Pornstars & Other"} - - {id: 34, cat: XXX, desc: " Паки порнороликов / Video Packs"} - - {id: 64, cat: XXX, desc: "Этническое порно / Ethnic Porn - Фильмы и ролики"} - - {id: 65, cat: XXX, desc: " Межрасовый секс / Interracial - Сцены и ролики"} - - {id: 68, cat: XXX, desc: " Негритянки и Темнокожие / Ebony & Arab - Сцены и ролики"} - - {id: 66, cat: XXX, desc: " Азиатки / Asian - Сцены и ролики"} - - {id: 67, cat: XXX, desc: " Латинки / Latin - Сцены и ролики"} - - {id: 83, cat: XXX, desc: " Полнометражные азиатские и этнические фильмы / Full-length ethnic films"} - - {id: 105, cat: XXX, desc: " Этническое порно / Ethnic Porn - Паки роликов и фильмов"} - - {id: 84, cat: XXX, desc: "Оральный секс / Blowjob & Deep Throat, Licking - Фильмы и ролики"} - - {id: 86, cat: XXX, desc: " Оральный секс / Blowjob & Deep Throat, Licking - Сцены и ролики"} - - {id: 87, cat: XXX, desc: " Групповой оральный секс / Blowbang & Bukkake - Сцены и ролики"} - - {id: 85, cat: XXX, desc: " Оральный секс / Blowjob & Deep Throat, Licking - Полнометражные фильмы"} - - {id: 61, cat: XXX, desc: "Masturbation, Solo & Posing - Фильмы и ролики"} - - {id: 62, cat: XXX, desc: " Мастурбация / Masturbation"} - - {id: 63, cat: XXX, desc: " Posing & Striptease"} - - {id: 2, cat: XXX, desc: "Любительское порно / Amateur & Homemade"} - - {id: 3, cat: XXX, desc: " Русское любительское XXX видео / Russian Amateur"} - - {id: 18, cat: XXX, desc: " Зарубежное любительское порно / Foreign amateur"} - - {id: 4, cat: XXX, desc: " Порно чаты / WEBCam"} - - {id: 10, cat: XXX, desc: " Скрытые камеры / Spycam"} - - {id: 5, cat: XXX, desc: " Паки любительского порно / Amateur porn packs"} - - {id: 110, cat: XXX, desc: "Только для зарегистрированных / For Registered Members Only"} - - {id: 112, cat: XXX, desc: " Полнометражные фильмы / Full-length Movies"} - - {id: 113, cat: XXX, desc: " Сцены и ролики / Scenes & Videos"} - - {id: 114, cat: XXX, desc: " Анимация 18+ / Animation 18+"} - - {id: 116, cat: XXX, desc: " Игры для взрослых / Porn games"} - - {id: 115, cat: XXX, desc: " Фото, Обои, Картинки / Photos, Wallpapers & Pictures"} - - {id: 117, cat: XXX, desc: " Видео паки / Video Packs"} - - {id: 16, cat: XXX, desc: "Фетиш и Нетрадиционное порно / Special Interest Movies & Clips"} - - {id: 43, cat: XXX, desc: "Rough Sex & BDSM - Фильмы и ролики"} - - {id: 17, cat: XXX, desc: " Rough Sex, BDSM & Bondage - Полнометражные фильмы"} - - {id: 46, cat: XXX, desc: " Постановочные изнасилования / Pseudo-Rape - Сцены и ролики"} - - {id: 41, cat: XXX, desc: " Rough Sex, BDSM & Bondage - Сцены и ролики"} - - {id: 45, cat: XXX, desc: " Ролики и фильмы от "LegalPorno ""} - - {id: 44, cat: XXX, desc: " Fisting & Dildo - Сцены и ролики"} - - {id: 47, cat: XXX, desc: "Фетиш / Fetish - Фильмы и ролики"} - - {id: 49, cat: XXX, desc: " Фетиш / Fetish - Полнометражные фильмы"} - - {id: 109, cat: XXX, desc: " Cuckold / Cheating - Сцены и ролики"} - - {id: 99, cat: XXX, desc: " Alt-Porn / Alt Girls - Сцены и ролики"} - - {id: 106, cat: XXX, desc: " Cosplay - Сцены и ролики"} - - {id: 53, cat: XXX, desc: " Fetish Wear / Latex, Uniform, Panties, Lingerie, Stockings - Сцены и ролики"} - - {id: 54, cat: XXX, desc: " Большие сиськи и жопы / Big Boobs & Big Ass - Сцены и ролики"} - - {id: 48, cat: XXX, desc: " Foot Fetish, Footjob & Feet - Сцены и ролики"} - - {id: 55, cat: XXX, desc: " Толстушки / BBW & Plump Woman - Сцены и ролики"} - - {id: 56, cat: XXX, desc: " Худенькие и Миниатюрные / Skinny & Petite - Сцены и ролики"} - - {id: 60, cat: XXX, desc: " Массаж / Massage - Сцены и ролики"} - - {id: 98, cat: XXX, desc: " Wet & Oiled - Сцены и ролики"} - - {id: 51, cat: XXX, desc: " Femdom & Domination - Сцены и ролики"} - - {id: 50, cat: XXX, desc: " Мохнатки / Hairy Pussy & Bush - Сцены и ролики"} - - {id: 57, cat: XXX, desc: " Squirting / Pissing & Pee - Сцены и ролики"} - - {id: 52, cat: XXX, desc: " Glory Hole - Сцены и ролики"} - - {id: 107, cat: XXX, desc: " Medical Fetish - Сцены и ролики"} - - {id: 58, cat: XXX, desc: " Беременные / Pregnant - Сцены и ролики"} - - {id: 104, cat: XXX, desc: " Пьяные и Спящие / Drunk & Sleeping - Сцены и ролики"} - - {id: 59, cat: XXX, desc: " Карлики / Midgets - Сцены и ролики"} - - {id: 108, cat: XXX, desc: " Разное / Various Fetishes - Сцены и ролики"} - - {id: 69, cat: XXX, desc: "Лесбиянки / Lesbians - Фильмы и ролики"} - - {id: 70, cat: XXX, desc: " Полнометражные лесби-фильмы / Full length lesbian movies"} - - {id: 71, cat: XXX, desc: " Лесбиянки / Lesbians - Сцены и ролики"} - - {id: 72, cat: XXX, desc: " Лесбийский страпон / Lesbian Strap-on - Сцены и ролики"} - - {id: 73, cat: XXX, desc: " All Girl / Lesbians - Video Pack"} - - {id: 42, cat: XXX, desc: "Fetish, Rough Sex & BDSM - Video Pack"} - - {id: 24, cat: XXX, desc: "Анимация, игры, фото и картинки"} - - {id: 23, cat: XXX, desc: "Анимация 18+ / Animation 18+"} - - {id: 74, cat: XXX, desc: " Мультфильмы18+ / Cartoons 18+"} - - {id: 75, cat: XXX, desc: " Хентай 18+ / Hentai 18+"} - - {id: 76, cat: XXX, desc: " Паки мультфильмов 18+ / Animation packs 18+"} - - {id: 77, cat: XXX, desc: "Фото, Обои и Картинки / Photos, Wallpapers & Pictures"} - - {id: 79, cat: XXX, desc: " Любительское фото / Amateur photo"} - - {id: 80, cat: XXX, desc: " Подборки сетов / Picture Sets Packs"} - - {id: 81, cat: XXX, desc: " Подборки по актрисам / Actresses Picture Packs"} - - {id: 82, cat: XXX, desc: " Сборники фото и обоев, разное / Collections of Photos & Wallpapers"} - - {id: 78, cat: XXX, desc: "Игры 18+ / Porn games"} - - {id: 102, cat: XXX, desc: " Визуальные новеллы / Visual Novels"} - - {id: 103, cat: XXX, desc: " Ролевые / Role-Playing"} - - {id: 88, cat: XXX, desc: "Общий форум"} - - {id: 100, cat: XXX, desc: "Правила & FAQ"} - - {id: 89, cat: XXX, desc: "Новости и объявления"} - - {id: 90, cat: XXX, desc: "Тех. раздел"} - - {id: 91, cat: XXX, desc: "Стол заказов"} - - {id: 92, cat: XXX, desc: "Помогите опознать!"} - - {id: 93, cat: XXX, desc: "Свободное общение"} - - {id: 94, cat: XXX, desc: "Форум релизёров"} - - {id: 95, cat: XXX, desc: " Скриншоты"} - - {id: 96, cat: XXX, desc: " MediaInfo"} - - {id: 97, cat: XXX, desc: " Разное"} - - {id: 101, cat: XXX, desc: "Архив"} - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: t - options: - t: created - ts: seeders - sz: size - i: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - -search: - paths: - # https://opensharing.org/search.php?tracker_search=torrent&keywords=2024&terms=all&author=&fid[]=3&fid[]=18&sc=1&sf=titleonly&sk=t&sd=d&sr=topics&st=0&ch=300&t=0&submit=Поиск - - path: search.php - inputs: - tracker_search: torrent - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - terms: all - author: "" - sc: 1 - sf: titleonly - sr: topics - sk: "{{ .Config.sort }}" - sd: "{{ .Config.type }}" - st: 0 - ch: 300 - t: 0 - submit: Поиск - $raw: "{{ range .Categories }}&fid[]={{.}}{{end}}" - - rows: - selector: table.tablebg > tbody > tr:has(a[href^="./download/file.php?id="]) - - fields: - category: - selector: a[href^="./viewtopic.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.topictitle - details: - selector: a.topictitle - attribute: href - download: - selector: a[href^="./download/file.php?id="] - attribute: href - size: - selector: p.gensmall > b - filters: - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: span.seed - leechers: - selector: span.leech - grabs: - selector: span.complet - date: - # (09.03.2020) - selector: a.topictitle - optional: true - default: now - # do not append TZ else text {{if}} will not work if date not found - filters: - - name: regexp - args: (\d{2}\.\d{2}\.\d{4}) - - name: dateparse - args: "dd.MM.yyyy" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oshenpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oshenpt.yml deleted file mode 100644 index 9c71bf8f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/oshenpt.yml +++ /dev/null @@ -1,203 +0,0 @@ ---- -id: oshenpt -name: OshenPT -description: "OshenPT is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.oshen.win/ -legacylinks: - - http://www.oshen.win/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 406, cat: Audio/Video, desc: "MusicVideo/音乐MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/运体"} - - {id: 409, cat: Other, desc: "Misc/音乐"} - - {id: 408, cat: Audio, desc: "HQ Audio/无损音乐"} - - {id: 410, cat: Console, desc: "Games/游戏"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the OshenPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (4 does not appear to work). - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.8 2025-10-01 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ourbits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ourbits.yml deleted file mode 100644 index 20e2d425..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ourbits.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: ourbits -name: OurBits -description: "OurBits (HDPter) is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ourbits.club/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: Movies/3D, desc: "Movies 3D"} - - {id: 405, cat: TV, desc: "TV Packs"} - - {id: 410, cat: TV/Documentary, desc: "Documentaries"} - - {id: 411, cat: TV/Anime, desc: "Animations"} - - {id: 412, cat: TV, desc: "TV Episodes"} - - {id: 413, cat: TV, desc: "TV Shows"} - - {id: 414, cat: Audio/Video, desc: "Music Videos"} - - {id: 415, cat: TV/Sport, desc: "Sports"} - - {id: 416, cat: Audio, desc: "Music"} - - {id: 419, cat: TV, desc: "Concert"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 7 days in a row.
" - -login: - # switched from the form method to the cookie method due to #8881 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="logout.php?token="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat[]={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0=incldead, 1=active, 2=dead - incldead: 0 - # show promotions: 0=all, 1=normal, 2=free, 3=2x, 4=2xFree, 5=50%, 6=2x50%, 7=30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0=title, 3=uploader, 4=imdb URL, 5=douban URL - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0=AND, 1=OR, 2=Exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) > table > tbody > tr > td - remove: a, b, font, img, span - filters: - - name: replace - args: [" 剩余时间:", ""] -# Ourbits 1.1.0 (Based on NexusPHP Standard v1.5 Beta 4) b0d8f5d 2025-10-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/p2pbg.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/p2pbg.yml deleted file mode 100644 index 51b18103..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/p2pbg.yml +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: p2pbg -name: P2PBG -description: "P2PBG is a BULGARIAN Private PAY2DL Torrent Tracker for 0DAY / GENERAL" -language: bg-BG -type: private -encoding: UTF-8 -links: - - https://www.p2pbg.com/ -legacylinks: - - http://www.p2pbg.com/ - - http://p2pbg.com/ # site does not support https ERR_CONNECTION_REFUSED - -caps: - categorymappings: - # movies - - {id: 1, cat: Movies/SD, desc: "Movies XviD"} - - {id: 7, cat: TV/Documentary, desc: "Movies Documentaries"} - - {id: 11, cat: Movies/DVD, desc: "Movies DVD"} - - {id: 14, cat: TV, desc: "TV Shows"} - - {id: 15, cat: TV, desc: "TV Boxset"} - - {id: 16, cat: Movies/HD, desc: "Movies x264"} - - {id: 17, cat: Movies/HD, desc: "Movies MPEG2"} - - {id: 18, cat: Movies/BluRay, desc: "Movies BR/HD DVD"} - - {id: 24, cat: TV/SD, desc: "TV BG"} - - {id: 34, cat: Movies, desc: "Movies BG"} - - {id: 35, cat: Movies, desc: "Movies GSM"} - - {id: 38, cat: TV/Anime, desc: "Cartoons"} - - {id: 55, cat: Movies/3D, desc: "Movies 3D"} - - {id: 58, cat: Movies/HD, desc: "Movies x265"} - - {id: 59, cat: Movies/SD, desc: "Movies VHS"} - - {id: 60, cat: Movies/UHD, desc: "Movies 4K"} - # music - - {id: 2, cat: Audio/MP3, desc: "Music Song"} - - {id: 3, cat: Audio/Video, desc: "Music DVD"} - - {id: 19, cat: Audio/Video, desc: "Music Video"} - - {id: 28, cat: Audio, desc: "Music Album"} - - {id: 29, cat: Audio, desc: "Music Discography"} - # games - - {id: 8, cat: PC/Games, desc: "Games PC ISO"} - - {id: 9, cat: PC/Games, desc: "Games PC Rips"} - - {id: 12, cat: Console/PS3, desc: "Games PS2"} - - {id: 20, cat: Console/PSP, desc: "Games PSP"} - - {id: 21, cat: Console/XBox, desc: "Games XboX"} - - {id: 22, cat: Console/XBox 360, desc: "Games X360"} - - {id: 23, cat: Console/PS3, desc: "Games PS3"} - - {id: 33, cat: Console/Wii, desc: "Games Wii"} - # appz - - {id: 26, cat: PC/ISO, desc: "Appz PC ISO"} - - {id: 27, cat: PC, desc: "Appz GSM"} - - {id: 30, cat: PC, desc: "Appz Linux"} - - {id: 31, cat: PC/0day, desc: "Appz Ripped"} - - {id: 49, cat: PC/Mac, desc: "Appz MacOS"} - # sports - - {id: 5, cat: TV/Sport, desc: "Formula 1"} - - {id: 51, cat: TV/Sport, desc: "Sport"} - - {id: 57, cat: TV/Sport, desc: "Formula 2"} - # other - - {id: 4, cat: Other, desc: "Other"} - - {id: 6, cat: Books, desc: "Books"} - - {id: 37, cat: Other/Misc, desc: "Pictures"} - - {id: 39, cat: Other, desc: "e-Learning"} - - {id: 40, cat: PC/Mobile-iOS, desc: "Mobile iPhone"} - - {id: 52, cat: PC/Mobile-Other, desc: "Mobile GSM"} - # 3x - - {id: 13, cat: XXX, desc: "XXX"} - - {id: 32, cat: XXX/ImageSet, desc: "XXX Pictures"} - - {id: 48, cat: XXX, desc: "XXX BG Amateurs"} - - {id: 53, cat: XXX, desc: "XXX Hentai"} - - {id: 54, cat: XXX/x264, desc: "XXX HD"} - - {id: 56, cat: XXX, desc: "XXX 3D"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_magnets - type: info - label: "About Magnets" - default: "IF you do not see Magnets in the results then this means that you are not a Super User or VIP.
You will need to visit the MarketPlace." - - name: info_results - type: info - label: "Search results" - default: "Only the Base style is supported with this indexer.
For best results, change the Torrents per page: setting to 100 on your account profile." - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - size: size - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: login - method: form - form: form.auth-form - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - remember: 1 - error: - - selector: div.auth-notice:contains("Incorrect") - test: - path: index.php - selector: a[href$="logout"] - -search: - paths: - # https://www.p2pbg.com/torrents?fakeusernameremembered=&fakepasswordremembered=&search=&category=0&active=0&hidexxx=1 - - path: torrents - allowEmptyInputs: true - inputs: - category: "{{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}" - fakeusernameremembered: "" - fakepasswordremembered: "" - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ end }}" - # 0 all 1, active, 2 dead - active: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.lista[width="100%"] tr > td > table.lista[width="100%"] tr:has(a[href*="category="]) - - fields: - category: - selector: a[href*="category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: td:nth-child(2) a - _hash: - selector: td:nth-child(2) a - attribute: onclick - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - details: - text: "torrents/{{ .Result._hash }}" - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - optional: true - poster: - selector: a:has(img[src*="/torrentimg/pic"]) - attribute: data-overlib - filters: - - name: regexp - args: src=\"(.*?)\" - # two flavours - date_day: - # Yesterday - # Вчера - selector: td:nth-last-child(5):not(:contains("/")) - # auto adjusted by site account profile - optional: true - filters: - - name: re_replace - args: ["(?i)сейчас|Сега", "now"] - - name: re_replace - args: ["(?i)сегодня|Днес", "Today"] - - name: re_replace - args: ["(?i)вчера|Вчера", "Yesterday"] - date_year: - # 14/03/2019 - selector: td:nth-last-child(5):contains("/") - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "dd/MM/yyyy" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - grabs: - selector: td:nth-last-child(1) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# xbtit customised diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/panda.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/panda.yml deleted file mode 100644 index a824cb50..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/panda.yml +++ /dev/null @@ -1,223 +0,0 @@ ---- -id: panda -name: Panda -description: "Panda is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://pandapt.net/ - -caps: - categorymappings: - # dont forget to update the path categories in the search block - - {id: 401, cat: Movies, desc: "Movies/电影", default: true} - - {id: 402, cat: TV, desc: "TV Series/电视剧", default: true} - - {id: 415, cat: TV, desc: "TV MiniSeries/短剧", default: true} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫", default: true} - - {id: 403, cat: TV, desc: "TV Shows/综艺", default: true} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片", default: true} - - {id: 407, cat: TV/Sport, desc: "Sports/体育", default: true} - - {id: 412, cat: PC, desc: "Software/软件", default: true} - - {id: 411, cat: Console, desc: "Games/游戏", default: true} - - {id: 413, cat: Audio/Video, desc: "Concert/演唱会/音乐会", default: true} - - {id: 409, cat: Other, desc: "Others/其他", default: true} - - {id: 410, cat: Books, desc: "E-Books/电子书", default: false} - - {id: 414, cat: Audio/Audiobook, desc: "Audiobook/有声书", default: false} - - {id: 406, cat: Audio/Video, desc: "Music Videos/音乐短片", default: false} - - {id: 408, cat: Audio, desc: "Music/音乐", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the Panda Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts would be deleted based on the following rules:
  1. Nexus Master or above would never be deleted.
  2. Veteran User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 415, 405, 403, 404, 407, 412, 411, 413, 409] - - path: special.php - categories: [410, 414, 406, 408] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.7 2023-09-22 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/parabellumhd-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/parabellumhd-api.yml deleted file mode 100644 index b4c606b4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/parabellumhd-api.yml +++ /dev/null @@ -1,213 +0,0 @@ ---- -id: parabellumhd-api -name: ParabellumHD (API) -description: "ParabellumHD is a SPANISH Private Torrent Tracker for HEVC & AV1-encoded MOVIES / TV" -language: es-ES -type: private -encoding: UTF-8 -links: - - https://parabellumhd.cx/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Películas"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 7, cat: Movies/Other, desc: "Películas Animación"} - - {id: 8, cat: TV/Other, desc: "Series Animación"} - - {id: 9, cat: Movies/Other, desc: "Películas Anime"} - - {id: 10, cat: TV/Anime, desc: "Series Anime"} - - {id: 6, cat: Other, desc: "Varios"} - - {id: 11, cat: Movies/UHD, desc: "Películas 4K"} - # undocumented - - {id: 4, cat: Console, desc: "Juegos"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your ParabellumHD account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - _resolution: - selector: resolution - _type: - selector: type - filters: - - name: replace - args: ["Encode", "BluRay"] - - name: replace - args: ["Full Disc", "BR-DISK"] - title_optional: - selector: name - filters: - - name: append - args: " {{ .Result._resolution }} {{ .Result._type }}" - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - filters: - - name: re_replace - args: ["(?i)\\bTemporada (\\d+)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\bEpisodio (\\d+)\\b", "E$1"] - - name: re_replace - args: ["(?i)(\\d+)x(\\d+)", "S$1E$2"] - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 1.0 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/party-tracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/party-tracker.yml deleted file mode 100644 index fbd0ea75..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/party-tracker.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: party-tracker -name: Party-Tracker -description: "Party-Tracker is a HUNGARIAN private tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: private -encoding: ISO-8859-2 -links: - - https://party-tracker.info/ - -caps: - categorymappings: - - {id: 47, cat: Console/XBox, desc: "Játék/Xbox"} - - {id: 46, cat: Console/PS3, desc: "Játék/Ps2"} - - {id: 45, cat: PC/Games, desc: "Játék/Pc"} - - {id: 44, cat: Movies/SD, desc: "Cam/hun"} - - {id: 69, cat: Movies/SD, desc: "Cam/eng"} - - {id: 43, cat: Movies/SD, desc: "Film/Hun/XviD"} - - {id: 42, cat: Movies/SD, desc: "Film/Eng/XviD"} - - {id: 41, cat: Movies/DVD, desc: "Film/Hun/DVD"} - - {id: 40, cat: Movies/DVD, desc: "Film/Eng/DVD"} - - {id: 39, cat: Books/EBook, desc: "E-book"} - - {id: 38, cat: TV/HD, desc: "HD/Hun"} - - {id: 57, cat: TV/SD, desc: "Sorozat/Eng"} - - {id: 66, cat: TV/HD, desc: "HD/Eng"} - - {id: 50, cat: PC/Mobile-Other, desc: "Mobil"} - - {id: 51, cat: PC/0day, desc: "Programok"} - - {id: 52, cat: TV/SD, desc: "Sorozat/Hun"} - - {id: 53, cat: XXX/x264, desc: "XXX/Film"} - - {id: 54, cat: XXX/ImageSet, desc: "XXX/Kép"} - - {id: 48, cat: Other, desc: "Képek"} - - {id: 49, cat: TV/Anime, desc: "Mese"} - - {id: 55, cat: Audio, desc: "Zene/Eng"} - - {id: 56, cat: Audio, desc: "Zene/Hun"} - - {id: 65, cat: Audio/Audiobook, desc: "Hangoskönyv"} - - {id: 67, cat: Audio/Video, desc: "Klipp"} - - {id: 68, cat: Books/EBook, desc: "Ebook/eng"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search FreeLeech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: added - 5: size - 7: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrent oldalanként: setting to 100 on your account profile. - -login: - method: post - path: takelogin.php - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table.main:contains("Hiba") - test: - path: index.php - selector: a[href^="logout.php?url="] - -search: - paths: - - path: letoltes.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # title, descr, actor, tag - ker: 0 - # 0 active, 1 active+idead, 2 dead, 3 free, 4 x2, 6 my torrents - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not support imdbid searches - - keywordsfilters: - # replace spaces between words with percent wildcard - - name: re_replace - args: ["\\W+", "%"] - - rows: - selector: table.main > tbody > tr:has(a[href^="letoltes.php?tipus="]) - - fields: - category: - selector: a[href^="letoltes.php?tipus="] - attribute: href - filters: - - name: querystring - args: tipus - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: span[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - genre: - selector: a[href^="/letoltes.php?cimke="] - description: - case: - img[src="/fajlok/kepek/yes.svg"]: "Verified: {{ .Result.genre }}" - "*": "Unverified: {{ .Result.genre }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(5) - remove: font - filters: - - name: replace - args: ["\xA0", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src="fajlok/kepek/free.gif"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="fajlok/kepek/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pctorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pctorrent.yml deleted file mode 100644 index 7c3ece4d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pctorrent.yml +++ /dev/null @@ -1,63 +0,0 @@ ---- -id: pctorrent -name: PC-torrent -description: "PC-torrent is a RUSSIAN Public Torrent Tracker for Games" -language: ru-RU -type: public -encoding: windows-1251 -links: - - https://pc-torrents.games/ -legacylinks: - - https://pc-torrent.org/ - - https://pc-torrent.pro/ - -caps: - categorymappings: - - {id: 1, cat: PC/Games, desc: "Games"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[href*="/engine/download.php?id="] - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}?do=search&subaction=search&search_start=0&full_search=1&result_from=1&story={{ .Keywords }}{{ else }}{{ end }}" - - rows: - selector: div.dshort:has(div.d3-raz):not(:has(div.d3-raz:contains("Анонс!"))) # no size means indirect download via 3rd party site, not supported by cardigann. #8020 - filters: - - name: andmatch # issue #8019 - - fields: - category: - text: 1 - title: - selector: div.d3-title - details: - selector: a - attribute: href - download: - selector: a - attribute: href - poster: - selector: img - attribute: src - date: - text: now - size: - selector: div.d3-raz - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# DataLife diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/peeratiko.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/peeratiko.yml deleted file mode 100644 index f75dea14..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/peeratiko.yml +++ /dev/null @@ -1,221 +0,0 @@ ---- -id: peeratiko -name: Peeratiko -description: "Peeratiko is a GREEK Private Torrent Tracker for MOVIES / TV / GENERAL" -language: el-GR -type: private -encoding: UTF-8 -certificates: - - 0ad8e189bb7d9e15499ba434d77b1e1b3656099f # Expired 30 Jun 2025 -links: - - https://peeratiko.org/ - -caps: - categorymappings: - - {id: 58, cat: PC, desc: "Applications Linux"} - - {id: 4, cat: PC/Mac, desc: "Applications Mac"} - - {id: 54, cat: PC/Mobile-Other, desc: "Applications Mobiles"} - - {id: 2, cat: PC, desc: "Applications Other"} - - {id: 3, cat: PC/0day, desc: "Applications Windows"} - - {id: 39, cat: Books/EBook, desc: "Books Books"} - - {id: 56, cat: Books/Comics, desc: "Books Comics"} - - {id: 47, cat: Books/Mags, desc: "Books Magazines"} - - {id: 57, cat: PC/Mobile-Other, desc: "Games Mobiles"} - - {id: 5, cat: Console/Other, desc: "Games Other"} - - {id: 8, cat: Console/PS3, desc: "Games PSP/PS2/PS3"} - - {id: 6, cat: PC/Games, desc: "Games Windows"} - - {id: 7, cat: Console/XBox, desc: "Games XBOX"} - - {id: 60, cat: Movies/3D, desc: "Movies 3D"} - - {id: 64, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 72, cat: Movies/UHD, desc: "Movies 4K Pack"} - - {id: 1, cat: Movies, desc: "Movies Anime/Cartoons"} - - {id: 67, cat: Movies/BluRay, desc: "Movies BluRay Remux"} - - {id: 9, cat: Movies/BluRay, desc: "Movies BluRay x264"} - - {id: 62, cat: Movies/DVD, desc: "Movies DVD-R"} - - {id: 15, cat: Movies/WEB-DL, desc: "DVD/BD/BR-rip x264"} - - {id: 16, cat: Movies/WEB-DL, desc: "Movies Gr. Subs DVD/BD/BR-rip"} - - {id: 18, cat: Movies, desc: "Movies Gr. Subs Embeddeed"} - - {id: 19, cat: Movies/Other, desc: "Movies Gr. Subs Other"} - - {id: 17, cat: Movies/DVD, desc: "Movies Greek DVD-R"} - - {id: 21, cat: Movies/WEB-DL, desc: "Movies Greek DVD/BD/BR-rip"} - - {id: 22, cat: Movies/Other, desc: "Movies Greek Other"} - - {id: 10, cat: Movies/HD, desc: "Movies HD Packs"} - - {id: 13, cat: Movies/Other, desc: "Movies Other"} - - {id: 14, cat: Movies, desc: "Movies Packs"} - - {id: 71, cat: Movies/WEB-DL, desc: "Movies Webdl"} - - {id: 69, cat: Movies/WEB-DL, desc: "Movies Webrip"} - - {id: 23, cat: Audio, desc: "Music DJs Stuff"} - - {id: 26, cat: Audio, desc: "Music English/Discographies"} - - {id: 24, cat: Audio, desc: "Music English/Foreign"} - - {id: 25, cat: Audio/Lossless, desc: "Music English/Foreign FLAC"} - - {id: 27, cat: Audio, desc: "Music Greek"} - - {id: 28, cat: Audio/Lossless, desc: "Music Greek FLAC"} - - {id: 29, cat: Audio, desc: "Music Greek/Discographies"} - - {id: 36, cat: Audio/Video, desc: "Music Video Clips"} - - {id: 61, cat: Other, desc: "Other Packs"} - - {id: 40, cat: Other/Misc, desc: "Other Misc"} - - {id: 59, cat: Other, desc: "Other Pictures"} - - {id: 68, cat: TV/HD, desc: "TV Bluray Remux"} - - {id: 11, cat: TV/Documentary, desc: "TV Documentaries"} - - {id: 12, cat: TV/HD, desc: "TV HD Series"} - - {id: 63, cat: TV/HD, desc: "TV HD Series Packs"} - - {id: 53, cat: TV/Other, desc: "TV Other"} - - {id: 31, cat: TV, desc: "TV Series Gr. Subs"} - - {id: 32, cat: TV, desc: "TV Series Gr. Subs Embedded"} - - {id: 33, cat: TV, desc: "TV Series Gr. Subs Packs"} - - {id: 34, cat: TV, desc: "TV Series Greek"} - - {id: 35, cat: TV, desc: "TV Series Greek Packs"} - - {id: 30, cat: TV/Sport, desc: "TV Sports"} - - {id: 65, cat: TV/UHD, desc: "TV Series 4K"} - - {id: 66, cat: TV/UHD, desc: "TV Series Pack 4K"} - - {id: 73, cat: TV/WEB-DL, desc: "TV Series Webdl"} - - {id: 70, cat: TV/WEB-DL, desc: "TV Series Webrip"} - - modes: - search: [q] - tv-search: [q, season, ep, genre] - movie-search: [q, genre] - music-search: [q, genre] - book-search: [q, genre] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page setting to 100 on your Personal Options from the Personal Settings menu on the Peeratiko webpage. - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive for more than 42 days are automatically deleted." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - use_ssl: 1 - keeplogged: 1 - error: - - selector: h2 - message: - selector: table tr td.text - test: - path: index.php - selector: a[href^="/logout.php?auth="] - -search: - paths: - # https://peeratiko.org/browse.php?cats2[]=39&cats5[]=28&cats5[]=29&cats5[]=36&search=&searchin=title&incldead=1 - - path: browse.php - inputs: - # note: site uses the catsX[]=nn method which cardigann does not support. - search: "{{ if .Query.Genre }}{{ .Query.Genre }} {{ else }}{{ end }}{{ .Keywords }}" - # title, descr, filelist, genre, tags, all - searchin: "{{ if .Query.Genre }}all{{ else }}title{{ end }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - only_free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not support searching imdbid and does not return imdb links - - rows: - selector: table[width="90%"] tbody tr:has(a[href^="download.php?torrent="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: ">(.+?)<" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - genre: - selector: strong > i - description: - case: - img[src="/pic/mod.gif"]: "verified: {{ .Result.genre }}" - "*": "Unverified: {{ .Result.genre }}" - poster: - selector: a[href^="details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=..(https.+).. width" - files: - selector: td:nth-last-child(8) - date_day: - # Today
08:10 AM - # Yesterday
06:44 PM - selector: td:nth-last-child(6):contains("day") - # auto adjusted by site account profile - optional: true - date_year: - # Dec 4 2021
10:20 AM - selector: td:nth-last-child(6):not(:contains("day")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM d yyyy hh:mm tt" - date: - text: "{{ if or .Result.date_day .Result.date_year }}{{ or .Result.date_day .Result.date_year }}{{ else }}now{{ end }}" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - "b:contains(\"[FREE]\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 7 day (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# U-232 V3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/peersfm.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/peersfm.yml deleted file mode 100644 index ae41f10f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/peersfm.yml +++ /dev/null @@ -1,165 +0,0 @@ ---- -id: peersfm -name: Peers.FM -description: "Peers.FM is a RUSSIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: private -encoding: windows-1251 -links: - - https://peers.fm/ - -caps: - categorymappings: - - {id: 18, cat: TV/Anime, desc: "Anime"} - - {id: 17, cat: PC, desc: "Database"} - - {id: 25, cat: TV, desc: "EDU"} - - {id: 14, cat: Movies/HD, desc: "HD Видео"} - - {id: 7, cat: Other, desc: "Images"} - - {id: 15, cat: PC/Mac, desc: "Mac"} - - {id: 26, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 24, cat: TV, desc: "TV Show"} - - {id: 1, cat: PC, desc: "WaReZ"} - - {id: 10, cat: XXX, desc: "XXX"} - - {id: 9, cat: Audio/Audiobook, desc: "Аудиокниги"} - - {id: 3, cat: PC/Games, desc: "Игры/GameZ"} - - {id: 2, cat: Audio/Video, desc: "Клипы/Концерты"} - - {id: 8, cat: Books/Mags, desc: "Книги/Журналы"} - - {id: 20, cat: Audio/Lossless, desc: "Музыка/Lossless"} - - {id: 11, cat: Audio/MP3, desc: "Музыка/Mp3"} - - {id: 13, cat: TV, desc: "Мультфильмы"} - - {id: 12, cat: Other, desc: "Разное/Miscellaneous"} - - {id: 4, cat: Movies/DVD, desc: "Фильмы DVD"} - - {id: 6, cat: Movies, desc: "Фильмы MPEG4"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "When are accounts automatically deleted?
  1. Activated accounts with zero download/share values ​​are deleted 30 days after registration
  2. Unbanned accounts (active accounts) of the User class are deleted after 120 days have passed since the last recorded activity
  3. Unbanned accounts (active accounts) of the Power User class are deleted after 180 days from the last recorded activity
  4. You should also keep in mind that the administration regularly deletes accounts with very low ratings.
" - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - SSL: 1 - error: - - selector: table[width="400"] tr td - test: - path: browse.php - selector: a[href="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0=active, 1=incl dead, 2=only dead, 3=FREE - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[width="940px"] > tbody > tr:has(a[href^="details.php?id="]) - - fields: - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - download: - selector: a[href^="download.php/"] - attribute: href - grabs: - selector: a[href^="download.php/"] - size: - selector: td:nth-child(6) - date: - selector: td:nth-child(5) - # auto adjusted by site account profile - filters: - # replace month abbreviations - - name: replace - args: ["янв", "Jan"] - - name: replace - args: ["фев", "Feb"] - - name: replace - args: ["мар", "Mar"] - - name: replace - args: ["апр", "Apr"] - - name: replace - args: ["мая", "May"] - - name: replace - args: ["июн", "Jun"] - - name: replace - args: ["июл", "Jul"] - - name: replace - args: ["авг", "Aug"] - - name: replace - args: ["сен", "Sep"] - - name: replace - args: ["окт", "Oct"] - - name: replace - args: ["ноя", "Nov"] - - name: replace - args: ["дек", "Dec"] - - name: dateparse - args: "HH:mm:ssdd MMM yyyy" - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src="pic/free_100.png"]: 0 - img[src="pic/free_75.png"]: 0.25 - img[src="pic/free_50.png"]: 0.5 - img[src="pic/free_25.png"]: 0.75 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/phoenixproject.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/phoenixproject.yml deleted file mode 100644 index ba3a73f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/phoenixproject.yml +++ /dev/null @@ -1,139 +0,0 @@ ---- -id: phoenixproject -name: Phoenix Project -description: "Phoenix Project is a Private MacOS software tracker" -language: en-US -type: private -encoding: UTF-8 -links: - - https://phoenixproject.app/ - -caps: - categorymappings: - - {id: 1, cat: PC/Mac, desc: Applications} - - {id: 2, cat: PC/Games, desc: Games} - - {id: 3, cat: PC/Mobile-iOS, desc: IOS Applications} - - {id: 4, cat: PC/Mobile-iOS, desc: IOS Games} - - {id: 5, cat: Other, desc: Graphics} - - {id: 6, cat: Audio, desc: Audio} - - {id: 7, cat: Other, desc: Tutorials} - - {id: 8, cat: Other, desc: Other} - - modes: - search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2fa_code - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the Phoenix Project Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - twofa: "{{ .Config.2fa_code }}" - keeplogged: 1 - error: - - selector: div.main div.warning-login - test: - path: torrents.php - selector: a[href^="logout.php?auth="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchstr: "{{ .Keywords }}" - # freetorrent: 0 normal, 1 freeleech, 2 neutral leech - freetorrent: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - group_results: 0 - action: advanced - order: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - searchsubmit: 1 - - rows: - selector: table.torrent_table tr.torrent:has(a[title="Download"]) - - fields: - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "\\[(\\d+?)\\]" - title: - selector: a[href^="torrents.php?id="] - details: - selector: a[href^="torrents.php?id="] - attribute: href - genre: - selector: div.tags a[href*="taglist"] - download: - selector: span > a[href^="torrents.php?action=download&id="] - attribute: href - poster: - selector: div.group_image > img - attribute: src - description: - text: "{{ .Result.genre }}" - date: - selector: span.time.tooltip - attribute: title - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM dd yyyy, HH:mm zzz" - size: - selector: td.td_size - files: - selector: td.td_file_count - seeders: - selector: td.td_seeders - leechers: - selector: td.td_leechers - grabs: - selector: td.td_snatched - downloadvolumefactor: - case: - strong.torrent_label.tl_free: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.6 -# Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pier720.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pier720.yml deleted file mode 100644 index 6123bd7f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pier720.yml +++ /dev/null @@ -1,200 +0,0 @@ ---- -id: pier720 -name: 720pier -description: "720pier is a RUSSIAN Private Torrent Tracker for HD SPORTS" -language: ru-RU -type: private -encoding: UTF-8 -links: - - https://720pier.ru/ -legacylinks: - - http://720pier.ru/ - -caps: - categorymappings: - - {id: 30, cat: TV/Sport, desc: "Sports"} - - {id: 32, cat: TV/Sport, desc: "Basketball"} - - {id: 34, cat: TV/Sport, desc: "Basketball - NBA"} - - {id: 87, cat: TV/Sport, desc: "Basketball - NBA Playoffs"} - - {id: 121, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2025"} - - {id: 119, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2024"} - - {id: 117, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2023"} - - {id: 113, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2022"} - - {id: 111, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2021"} - - {id: 108, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2020"} - - {id: 106, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2019"} - - {id: 103, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2018"} - - {id: 95, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2017"} - - {id: 81, cat: TV/Sport, desc: "Basketball - NBA Playoffs - 2016"} - - {id: 58, cat: TV/Sport, desc: "Basketball - NBA Classic Games"} - - {id: 105, cat: TV/Sport, desc: "Basketball - NBA The Finals"} - - {id: 52, cat: TV/Sport, desc: "Basketball - NCAA"} - - {id: 82, cat: TV/Sport, desc: "Basketball - WNBA"} - - {id: 36, cat: TV/Sport, desc: "Basketball - European basketball"} - - {id: 37, cat: TV/Sport, desc: "Basketball - World Championship"} - - {id: 51, cat: TV/Sport, desc: "Basketball - Reviews and highlights"} - - {id: 41, cat: TV/Sport, desc: "Basketball - Other"} - - {id: 38, cat: TV/Sport, desc: "Basketball - Olympic Games"} - - {id: 42, cat: TV/Sport, desc: "Football"} - - {id: 43, cat: TV/Sport, desc: "Football - NFL"} - - {id: 66, cat: TV/Sport, desc: "Football - Super Bowls"} - - {id: 53, cat: TV/Sport, desc: "Football - NCAA"} - - {id: 99, cat: TV/Sport, desc: "Football - CFL"} - - {id: 110, cat: TV/Sport, desc: "Football - USFL"} - - {id: 54, cat: TV/Sport, desc: "Football - Reviews and highlights"} - - {id: 97, cat: TV/Sport, desc: "Football - Documentaries"} - - {id: 44, cat: TV/Sport, desc: "Football - Other"} - - {id: 101, cat: TV/Sport, desc: "Football - AAF"} - - {id: 46, cat: TV/Sport, desc: "Hockey"} - - {id: 48, cat: TV/Sport, desc: "Hockey - NHL"} - - {id: 88, cat: TV/Sport, desc: "Hockey - NHL Playoffs"} - - {id: 122, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2025"} - - {id: 120, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2024"} - - {id: 118, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2023"} - - {id: 114, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2022"} - - {id: 112, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2021"} - - {id: 102, cat: TV/Sport, desc: "Hockey - NHL Playoffs 2018-2020"} - - {id: 93, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2017"} - - {id: 80, cat: TV/Sport, desc: "Hockey - NHL Playoffs - 2016"} - - {id: 65, cat: TV/Sport, desc: "Hockey - Stanley Cup Finals"} - - {id: 92, cat: TV/Sport, desc: "Hockey - NCAA"} - - {id: 49, cat: TV/Sport, desc: "Hockey - World Championship"} - - {id: 123, cat: TV/Sport, desc: "Hockey - Olympic Games"} - - {id: 68, cat: TV/Sport, desc: "Hockey - Documentaries"} - - {id: 64, cat: TV/Sport, desc: "Hockey - Reviews and highlights"} - - {id: 50, cat: TV/Sport, desc: "Hockey - Other"} - - {id: 55, cat: TV/Sport, desc: "Baseball"} - - {id: 71, cat: TV/Sport, desc: "Baseball - MLB"} - - {id: 107, cat: TV/Sport, desc: "Baseball - MLB World Series"} - - {id: 72, cat: TV/Sport, desc: "Baseball - Other"} - - {id: 85, cat: TV/Sport, desc: "Baseball - Reviews, highlights, documentaries"} - - {id: 45, cat: TV/Sport, desc: "Other sports"} - - {id: 79, cat: TV/Sport, desc: "Other sports - Rugby"} - - {id: 78, cat: TV/Sport, desc: "Other sports - Lacrosse"} - - {id: 77, cat: TV/Sport, desc: "Other sports - Cricket"} - - {id: 76, cat: TV/Sport, desc: "Other sports - Volleyball"} - - {id: 75, cat: TV/Sport, desc: "Other sports - Tennis"} - - {id: 74, cat: TV/Sport, desc: "Other sports - Fighting"} - - {id: 94, cat: TV/Sport, desc: "Other sports - Misc"} - - {id: 100, cat: TV/Sport, desc: "Soccer"} - - {id: 98, cat: TV/Sport, desc: "Soccer - FIFA World Cup"} - - {id: 56, cat: TV/Sport, desc: "Sports on tv"} - - {id: 70, cat: TV/Sport, desc: "Sports on tv ESPN Films"} - # not in search_forum drop down - - {id: 73, cat: TV/Sport, desc: "Other sports - Auto, moto racing"} - # left out 39, 60 and 40 since they are not torrent forums - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: sort - type: select - label: Sort requested from site - default: t - options: - t: created - ts: seeders - sz: size - i: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - -login: - # switched from the form method to the cookie method in Nov 2020, reason not provided - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: :has(a[href^="./ucp.php?mode=logout&"]) - -download: - selectors: - - selector: a[href*="/download/torrent?id="] - attribute: href - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - paths: - - path: search.php - inputs: - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - terms: all - author: "" - sc: 1 - sf: firstpost - sr: topics - sk: "{{ .Config.sort }}" - sd: "{{ .Config.type }}" - st: 0 - ch: 300 - t: 0 - submit: Search - - rows: - # drop topics without size as they will not have download links in details page - selector: li.row:has(div.list-inner div[style="float:right;"]) - filters: - - name: andmatch - - fields: - title: - selector: a.topictitle - details: - selector: a.topictitle - attribute: href - category: - selector: a[href^="./viewforum.php?f="] - attribute: href - filters: - - name: querystring - args: f - download: - selector: a.topictitle - attribute: href - size: - selector: div.list-inner div[style="float:right;"] - optional: true - default: 1 GB - seeders: - selector: dd.posts span.my_tt.seed - optional: true - default: 0 - leechers: - selector: dd.posts span.my_tt.leech - optional: true - default: 0 - grabs: - selector: dd.views span.my_tt.complet - optional: true - default: 0 - date: - selector: div.left-box time - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.8 - minimumseedtime: - # 60 hours (as seconds = 60 x 60 x 60) - text: 216000 -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pignetwork.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pignetwork.yml deleted file mode 100644 index 8265416a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pignetwork.yml +++ /dev/null @@ -1,198 +0,0 @@ ---- -id: pignetwork -name: PigNetwork (猪猪网) -description: "PigNetwork (猪猪网) is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://piggo.me/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影", default: true} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片", default: true} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫", default: true} - - {id: 402, cat: TV, desc: "TV Series/电视剧", default: true} - - {id: 403, cat: TV, desc: "TV Shows/综艺", default: true} - - {id: 406, cat: Audio/Video, desc: "MusicVideo/MV", default: true} - - {id: 407, cat: TV/Sport, desc: "Sports/体育", default: true} - - {id: 409, cat: Other, desc: "Misc/其他", default: true} - - {id: 408, cat: Audio, desc: "Music/音乐", default: true} - - {id: 909, cat: Movies, desc: "Movies/电 影 🎬", default: false} - - {id: 908, cat: TV/Anime, desc: "Animation Series/剧集动画", default: false} - - {id: 905, cat: Audio, desc: "Audiobooks/有声读物", default: false} - - {id: 910, cat: Books, desc: "Picture Books/读书绘本", default: false} - - {id: 907, cat: TV/Documentary, desc: "Documentaries/纪录片🏞️", default: false} - - {id: 911, cat: Audio, desc: "Music/音 乐 🎧", default: true} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_date - type: info - label: Date Setting - default: Access your 个人中心 网站设定 (UserCP Website settings) and set 时间显示类型 (Time Display Type) to 发生时间 (Time of Occurence). Elapsed Time is not supported by this indexer and will see zero results. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  • Nexus Master users and above will be retained forever
  • Elite User users and above will not have their accounts deleted after parking their accounts (in the control panel)
  • Users who have parked their accounts will be deleted if they do not log in for 180 consecutive days
  • Users whose accounts have not been parked will have their accounts deleted if they do not log in for 90 consecutive days
  • Users who have no traffic for 7 consecutive days or who have not logged in for 30 consecutive days (that is, the upload/download data are both 0) will be automatically banned by the system.
" - -login: - # using cookie method because site uses Safeline WAF protection - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="/usercp.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 403, 404, 405, 406, 407, 408, 409] - - path: special.php - categories: [909, 908, 905, 910, 907, 911] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (4 does not appear to work) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - _id: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: querystring - args: id - download: - text: "download.php?id={{ .Result._id }}" - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date: - # time added - selector: td.rowfollow:nth-child(4) - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.7 2025-09-24 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pixelcove.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pixelcove.yml deleted file mode 100644 index 5c7b9214..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pixelcove.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: pixelcove -name: PixelCove -description: "PixelCove (Ultimate Gamer) is a Private Torrent Tracker for GAMES" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.pixelcove.me/ - -caps: - categorymappings: - # Computer - - {id: 10, cat: PC/Games, desc: "Windows"} - - {id: 11, cat: PC/Mac, desc: "Macintosh"} - - {id: 47, cat: PC, desc: "Linux"} - - {id: 56, cat: PC, desc: "VR"} - # Microsoft - - {id: 14, cat: Console/XBox, desc: "Xbox"} - - {id: 61, cat: Console/XBox 360, desc: "Xbox 360"} - - {id: 62, cat: Console/XBox One, desc: "Xbox One"} - # Sony - - {id: 43, cat: Console/PS3, desc: "Playstation "} - - {id: 63, cat: Console/PS3, desc: "Playstation 2"} - - {id: 64, cat: Console/PS3, desc: "Playstation 3"} - - {id: 67, cat: Console/PS4, desc: "Playstation 4"} - - {id: 12, cat: Console/Other, desc: "PSN"} - # Nintendo - - {id: 57, cat: Console/Other, desc: "Gamecube"} - - {id: 44, cat: Console/Wii, desc: "Wii"} - - {id: 46, cat: Console/Wii, desc: "Wii U"} - - {id: 76, cat: Console/Other, desc: "Switch"} - # Handheld - - {id: 15, cat: Console/NDS, desc: "DS"} - - {id: 68, cat: Console/NDS, desc: "3DS"} - - {id: 69, cat: Console/PSP, desc: "PSP"} - - {id: 70, cat: Console/PS Vita, desc: "PSVita"} - # Mobile - - {id: 65, cat: PC/Mobile-iOS, desc: "iOS"} - - {id: 49, cat: PC/Mobile-Android, desc: "Android"} - - {id: 66, cat: PC/Mobile-Other, desc: "Windows Mobile"} - # Reading - - {id: 53, cat: Books, desc: "Books/Mags"} - - {id: 60, cat: Books, desc: "Comics"} - - {id: 17, cat: Books, desc: "Guides"} - # Media - - {id: 58, cat: Audio/Video, desc: "Gaming Videos"} - - {id: 52, cat: Audio, desc: "OST"} - - {id: 55, cat: Other, desc: "Time for a Break"} - - {id: 81, cat: Audio/Video, desc: "Videos"} - # Various - - {id: 59, cat: Other, desc: "Mods"} - - {id: 54, cat: Other, desc: "Updates/Fixes"} - - {id: 71, cat: PC/0day, desc: "Applications/Tools"} - - {id: 48, cat: Other, desc: "Retro"} - - {id: 72, cat: Other, desc: "Board Games"} - - {id: 75, cat: Other, desc: "Paper Crafting"} - - {id: 77, cat: Other, desc: "Arcade"} - - {id: 78, cat: Other, desc: "Other"} - - modes: - search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the PixelCove2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must login regularly to maintain an active account. For User and Member, you must login once every 4 months. For Power User, Master and Elite you must login once per year. If you fail to do this your account will be disabled." - -login: - path: login - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cinfo: "2550|1350|24|-60" - keeploggedin: 1 - error: - - selector: div.error - test: - path: torrents.php - selector: a.username - -search: - paths: - # https://www.pixelcove.me/torrents.php?order_by=time&order_way=desc&filter_freeleech=1&searchtext=&action=advanced&title=&sizeall=&sizetype=gb&sizerange=0.01&filelist=&taglist=&autocomplete_toggle=1 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - searchtext: "" - title: "{{ .Keywords }}" - filelist: "" - taglist: "" - - rows: - # exclude redbar torrents - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - title: - selector: a[href^="/torrents.php?id="] - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "\\[(\\d+?)\\]" - details: - selector: a[href^="/torrents.php?id="] - attribute: href - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date_elapsed: - # Oct 01 2022, 18:41 - selector: td:nth-child(5) > span:not(:contains("ago")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - date_added: - # 13 hours ago - selector: td:nth-child(5) > span:contains("ago") - attribute: title - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 -# luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pixelcove2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pixelcove2fa.yml deleted file mode 100644 index 12a77297..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pixelcove2fa.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: pixelcove2fa -name: PixelCove2FA -description: "PixelCove2FA (Ultimate Gamer) is a Private Torrent Tracker for GAMES. Cookie Login for 2FA use." -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.pixelcove.me/ - -caps: - categorymappings: - # Computer - - {id: 10, cat: PC/Games, desc: "Windows"} - - {id: 11, cat: PC/Mac, desc: "Macintosh"} - - {id: 47, cat: PC, desc: "Linux"} - - {id: 56, cat: PC, desc: "VR"} - # Microsoft - - {id: 14, cat: Console/XBox, desc: "Xbox"} - - {id: 61, cat: Console/XBox 360, desc: "Xbox 360"} - - {id: 62, cat: Console/XBox One, desc: "Xbox One"} - # Sony - - {id: 43, cat: Console/PS3, desc: "Playstation "} - - {id: 63, cat: Console/PS3, desc: "Playstation 2"} - - {id: 64, cat: Console/PS3, desc: "Playstation 3"} - - {id: 67, cat: Console/PS4, desc: "Playstation 4"} - - {id: 12, cat: Console/Other, desc: "PSN"} - # Nintendo - - {id: 57, cat: Console/Other, desc: "Gamecube"} - - {id: 44, cat: Console/Wii, desc: "Wii"} - - {id: 46, cat: Console/Wii, desc: "Wii U"} - - {id: 76, cat: Console/Other, desc: "Switch"} - # Handheld - - {id: 15, cat: Console/NDS, desc: "DS"} - - {id: 68, cat: Console/NDS, desc: "3DS"} - - {id: 69, cat: Console/PSP, desc: "PSP"} - - {id: 70, cat: Console/PS Vita, desc: "PSVita"} - # Mobile - - {id: 65, cat: PC/Mobile-iOS, desc: "iOS"} - - {id: 49, cat: PC/Mobile-Android, desc: "Android"} - - {id: 66, cat: PC/Mobile-Other, desc: "Windows Mobile"} - # Reading - - {id: 53, cat: Books, desc: "Books/Mags"} - - {id: 60, cat: Books, desc: "Comics"} - - {id: 17, cat: Books, desc: "Guides"} - # Media - - {id: 58, cat: Audio/Video, desc: "Gaming Videos"} - - {id: 52, cat: Audio, desc: "OST"} - - {id: 55, cat: Other, desc: "Time for a Break"} - - {id: 81, cat: Audio/Video, desc: "Videos"} - # Various - - {id: 59, cat: Other, desc: "Mods"} - - {id: 54, cat: Other, desc: "Updates/Fixes"} - - {id: 71, cat: PC/0day, desc: "Applications/Tools"} - - {id: 48, cat: Other, desc: "Retro"} - - {id: 72, cat: Other, desc: "Board Games"} - - {id: 75, cat: Other, desc: "Paper Crafting"} - - {id: 77, cat: Other, desc: "Arcade"} - - {id: 78, cat: Other, desc: "Other"} - - modes: - search: [q] - music-search: [q] - book-search: [q] - -settings: - # using cookie method because login page can present second page for 2FA when enabled - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: time - options: - time: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must login regularly to maintain an active account. For User and Member, you must login once every 4 months. For Power User, Master and Elite you must login once per year. If you fail to do this your account will be disabled." - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: torrents.php - selector: a.username - -search: - paths: - # https://www.pixelcove.me/torrents.php?order_by=time&order_way=desc&filter_freeleech=1&searchtext=&action=advanced&title=&sizeall=&sizetype=gb&sizerange=0.01&filelist=&taglist=&autocomplete_toggle=1 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - order_by: "{{ .Config.sort }}" - order_way: "{{ .Config.type }}" - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - searchtext: "" - title: "{{ .Keywords }}" - filelist: "" - taglist: "" - - rows: - selector: table#torrent_table > tbody > tr[class^="torrent row"]:has(a[href*="action=download"]) - - fields: - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description_tags: - selector: div.tags - description: - case: - span.icon_okay: "Verified: {{ .Result.description_tags }}" - "*": "Unverified: {{ .Result.description_tags }}" - title: - selector: a[href^="/torrents.php?id="] - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "\\[(\\d+?)\\]" - details: - selector: a[href^="/torrents.php?id="] - attribute: href - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date_elapsed: - # Oct 01 2022, 18:41 - selector: td:nth-child(5) > span:not(:contains("ago")) - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - date_added: - # 13 hours ago - selector: td:nth-child(5) > span:contains("ago") - attribute: title - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "MMM dd yyyy, HH:mm" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - case: - span.icon[title*="DoubleSeed"]: 2 - "img[alt=\"DoubleSeed\"]": 2 - "*": 1 - minimumratio: - text: 0.5 -# luminance diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/playletpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/playletpt.yml deleted file mode 100644 index e7d8f2ef..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/playletpt.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: playletpt -name: PlayletPT -description: "PlayletPT is a CHINESE Private Torrent Tracker for Short Drama" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://playlet.cc/ -legacylinks: - - https://playletpt.xyz/ - -caps: - categorymappings: - - {id: 408, cat: TV, desc: "短剧/Drama"} - - {id: 409, cat: TV, desc: "漫画/Comedy"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 1 days (as seconds = 1 x 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/plugintorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/plugintorrent.yml deleted file mode 100644 index c9f389f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/plugintorrent.yml +++ /dev/null @@ -1,80 +0,0 @@ ---- -id: plugintorrent -name: plugintorrent -description: "plugintorrent is a Public site for AUDIO apps, plugins and samples" -language: en-US -type: public -encoding: UTF-8 -links: - - https://plugintorrent.com/ - -caps: - categories: - PC: PC - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - relevance: relevance - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -download: - selectors: - - selector: a[href^="magnet:?xt"] - attribute: href - -search: - # https://plugintorrent.com/?s=&orderby=date&order=DESC&post_type=post - paths: - - path: / - allowEmptyInputs: true - inputs: - s: "{{ .Keywords }}" - orderby: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - post_type: post - - rows: - selector: article:has(a) - filters: - - name: andmatch - - fields: - category: - text: PC - title: - selector: .entry-title - details: - selector: .entry-title > a - attribute: href - download: - selector: .entry-title > a - attribute: href - description: - selector: .entry-summary - date: - selector: span.date - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# wordpress 6.7.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/podzemlje.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/podzemlje.yml deleted file mode 100644 index e6fc50ec..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/podzemlje.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: podzemlje -name: Podzemlje -description: "Podzemlje is an EX-YU Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://podzemlje.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: PC/Games, desc: "Igre"} - - {id: 4, cat: Audio, desc: "Muzika"} - - {id: 5, cat: PC/0day, desc: "Programi"} - - {id: 6, cat: Books, desc: "Knjige"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Podzemlje account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.7 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.7 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polishtorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polishtorrent.yml deleted file mode 100644 index aaa00d8b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polishtorrent.yml +++ /dev/null @@ -1,246 +0,0 @@ ---- -id: polishtorrent -name: Polish Torrent (API) -description: "Polish Torrent (PTT) is a POLISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: private -encoding: UTF-8 -links: - - https://polishtorrent.top/ - -caps: - categorymappings: - - {id: 32, cat: Movies/Other, desc: "Anime/Filmy"} - - {id: 31, cat: TV/Anime, desc: "Anime/Seriale"} - - {id: 6, cat: PC/0day, desc: "Aplikacje/Programy"} - - {id: 25, cat: Audio/Audiobook, desc: "Audiobook"} - - {id: 4, cat: Movies, desc: "Bajki"} - - {id: 12, cat: Movies/BluRay, desc: "BD25/50_5/9"} - - {id: 20, cat: Books, desc: "Book"} - - {id: 13, cat: Movies/DVD, desc: "DVD5/9"} - - {id: 14, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 23, cat: Movies/UHD, desc: "Filmy 4K"} - - {id: 1, cat: Movies/HD, desc: "Filmy HD"} - - {id: 16, cat: Movies/SD, desc: "Filmy SD"} - - {id: 15, cat: Movies, desc: "Filmy bez lektora"} - - {id: 34, cat: Movies/Other, desc: "Filmy HDTS - Kino"} - - {id: 17, cat: Movies/Other, desc: "Filmy inne"} - - {id: 5, cat: PC/Games, desc: "Gry PC"} - - {id: 24, cat: PC/Games, desc: "Gry dodatki"} - - {id: 10, cat: Console, desc: "Gry Konsole"} - - {id: 19, cat: Other, desc: "Inne"} - - {id: 33, cat: Movies, desc: "iNTERNAL"} - - {id: 18, cat: PC/Mobile-Android, desc: "Mobile"} - - {id: 8, cat: Audio, desc: "Muzyka"} - - {id: 37, cat: TV, desc: "Reality TV"} - - {id: 11, cat: Movies, desc: "Paczki"} - - {id: 36, cat: TV, desc: "Seriale TV - Całe Sezony"} - - {id: 21, cat: TV/Sport, desc: "Sport"} - - {id: 29, cat: Movies, desc: "Stare Filmy"} - - {id: 27, cat: TV/Other, desc: "Teatr Telewizyjny"} - - {id: 2, cat: TV/UHD, desc: "TV 4K"} - - {id: 28, cat: TV/Documentary, desc: "TV Dokument"} - - {id: 30, cat: TV, desc: "TV Seriale Bajki"} - - {id: 9, cat: TV/HD, desc: "TV Seriale HD"} - - {id: 22, cat: TV/SD, desc: "TV Seriale SD"} - - {id: 38, cat: TV, desc: "Reality TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - allowtvsearchimdb: true - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Polish Torrent account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: multilang - type: checkbox - label: Replace MULTI by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTI by this language - default: POLISH - options: - POLISH: POLISH - MULTI POLISH: MULTI POLISH - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "
  • login as much as possible – no login for 30 days results in the account as DISABLED.
  • Lack of login for another 60 days causes permanent account blocking.
  • after 90 days of inactivity, the account is automatically deleted by the system.
  • This policy applies to users in the following groups: User, PowerUser, SuperUser, and Leech.
" - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title_phase1: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Akcja i Przygoda)", "Akcja_i_Przygoda"] - - name: re_replace - args: ["(?i)(film TV)", "film_TV"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.7 but torrents must be seeded for 5 days regardless of ratio -# minimumratio: -# text: 0.7 - minimumseedtime: - # 10 days (as seconds = 10 x 24 x 60 x 60) - text: 864000 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polishtracker-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polishtracker-api.yml deleted file mode 100644 index 7602c31d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polishtracker-api.yml +++ /dev/null @@ -1,145 +0,0 @@ ---- -id: polishtracker-api -name: PolishTracker (API) -description: "PolishTracker is a POLISH Private Torrent Tracker for 0DAY / MOVIES / GENERAL" -language: pl-PL -type: private -encoding: UTF-8 -links: - - https://pte.nu/ -legacylinks: - - https://polishtracker.net/ - -caps: - categorymappings: - - {id: 1, cat: PC/0day, desc: "0-Day"} - - {id: 2, cat: Audio/Video, desc: "Music Video"} - - {id: 3, cat: PC/0day, desc: "Apps"} - - {id: 4, cat: Console, desc: "Consoles"} - - {id: 5, cat: Books, desc: "E-book"} - - {id: 6, cat: Movies/HD, desc: "Movies HD"} - - {id: 7, cat: Movies/SD, desc: "Movies SD"} - - {id: 8, cat: Audio, desc: "Music"} - - {id: 9, cat: Movies/UHD, desc: "Movies UHD"} - - {id: 10, cat: PC/Games, desc: "PC Games"} - - {id: 11, cat: TV/HD, desc: "TV HD"} - - {id: 12, cat: TV/SD, desc: "TV SD"} - - {id: 13, cat: XXX, desc: "XXX"} - - {id: 14, cat: TV/UHD, desc: "TV-UHD"} - - {id: 15, cat: Audio/Audiobook, desc: "Audiobook"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find your API Key by accessing your PolishTracker account Settings page and clicking on the API section." - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi.POLISH: MULTi.POLISH - -# login: -# path: "https://api-test.pte.nu/api/v1/torrents" -# method: get -# inputs: -# tpage: 1 -# error: -# - selector: ":root:contains(\"ACCESS_DENIED\")" -# message: -# text: "The API key was not accepted by {{ .Config.sitelink }}." - -search: - paths: - # docs: https://cdn.pte.nu/docs/index.html - - path: "https://api-test.pte.nu/api/v1/torrents" - response: - type: json - - inputs: - search: "{{ .Keywords }}" - tpage: 1 - imdb_id: "{{ .Query.IMDBIDShort }}" - # search in nfo text also - broken - # nfo: false - # search is more broad - broken - # wide: false - $raw: "{{ range .Categories }}&cat[]={{.}}{{end}}" - # can search by genre but need range support. &tags[]=Action&tags[]=Comedy for Action and Comedy - - headers: - API-Key: ["{{ .Config.apikey }}"] - - rows: - selector: torrents - filters: - - name: andmatch - count: - selector: count - - fields: - _id: - selector: id - category: - selector: category - title_phase1: - selector: name - title_multilang: - selector: name - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - text: "{{ .Config.sitelink }}torrents/{{ .Result._id }}" - download: - text: "/download/{{ .Result._id }}" - imdbid: - selector: imdb_id - genre: - selector: tags - filters: - - name: validate - args: "Animation, Comedy, Family, Strategy, Action, Adventure, Indie, RPG, Simulation, Early, Crime, Thriller, Drama, Rock, Fantasy, Sci-Fi, Horror, Pop, War, Mystery, Oldies, Hardcore, Sport, Biography, Music, Rap, Romance, Dance, Hip-Hop, House, Punk Rock, Disco, Casual, Bass, History, Racing, Metal, Electronic, Alternative, Funk, Short, Classical, Acoustic, Soundtrack, Punk, Ambient, Talk-Show, Sports, Reggae, Documentary, Progressive Rock, Other, Western, Dance Hall, Trance, Folk, Classic Rock, Jazz, Hard Rock, Trip-Hop, R&B, Blues, Musical, Club, Techno, Cabaret, Black Metal, Easy Listening, Goa, Free, Massively, Reality-TV, Grunge, SynthPop, Ballad, Top 40, News, Industrial, Psychedelic Rock, Heavy Metal, Beat, Alternative Rock, Drum & Bass, Film-Noir, Rock & Roll, Death Metal, Lo-Fi, Country, Instrumental Pop, Game-Show, Soul, Retro, Noise, Latin, Design, Education, Software, Utilities, Pop-Folk, Instrumental, Game, Acid Jazz, Acid, Gothic Rock, Fusion, Darkwave, Meditative, Crossover, Thrash Metal, New Wave, Opera, Ethnic, Instrumental Rock, New Age, Gangsta, Speech, Gothic, Gospel, Symphonic Rock, Ska, JPop, Avantgarde, Tango, Vocal, Folk-Rock, Celtic" - description: - text: "{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: completed - date: - # 2022-08-09T17:37:03.23587+02:00 - selector: added - size: - selector: size - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 day (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polskie-torrenty.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polskie-torrenty.yml deleted file mode 100644 index 7caf82ad..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/polskie-torrenty.yml +++ /dev/null @@ -1,206 +0,0 @@ ---- -id: polskie-torrenty -name: Polskie-Torrenty -description: "Polskie-Torrenty is a POLISH Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: semi-private -encoding: UTF-8 -links: - - https://polskie-torrenty.eu/ - -caps: - categorymappings: - # Filmy - - {id: 27, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 30, cat: Movies/SD, desc: "Filmy DivX/XviD"} - - {id: 71, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 72, cat: Movies/HD, desc: "Filmy x264/h264/1080p"} - - {id: 73, cat: Movies/HD, desc: "Filmy x264/h264/720p"} - - {id: 74, cat: Movies/HD, desc: "Filmy x265/h265"} - - {id: 75, cat: Movies/HD, desc: "Filmy x264/h264"} - - {id: 76, cat: Movies/HD, desc: "Filmy BluRay / REMUX"} - - {id: 77, cat: Movies/UHD, desc: "Filmy 4K UHD"} - - {id: 78, cat: Movies/SD, desc: "Filmy IVO / AI"} - - {id: 79, cat: Movies/SD, desc: "Filmy TS/CAM"} - - {id: 88, cat: Movies/HD, desc: "Filmy x265/h265/1080p"} - - {id: 89, cat: Movies/HD, desc: "Filmy x265/h265/720p"} - # Seriale - - {id: 25, cat: TV, desc: "Seriale Polskie"} - - {id: 26, cat: TV/Foreign, desc: "Seriale Zagraniczne"} - - {id: 122, cat: TV, desc: "Seriale Fiona 9"} - # Dla Dzieci - - {id: 32, cat: Movies, desc: "Filmy/Bajki"} - - {id: 111, cat: Audio/Audiobook, desc: "Audiobook"} - - {id: 112, cat: Console, desc: "Gry"} - - {id: 113, cat: Other, desc: "Inne"} - - {id: 114, cat: Books, desc: "Kolorowanki"} - - {id: 115, cat: Books, desc: "Książki"} - - {id: 116, cat: Audio, desc: "Muzyka"} - - {id: 117, cat: PC, desc: "Programy"} - - {id: 118, cat: Audio/Video, desc: "Teledyski"} - # Gry - - {id: 34, cat: PC/Games, desc: "Gry PC"} - - {id: 35, cat: Console/PSP, desc: "PS2/PS3/PS4/PSP/PS VITA"} - - {id: 36, cat: Console/XBox, desc: "Xbox One/XBOX 360/Xbox"} - - {id: 37, cat: Console/NDS, desc: "NINTENDO / WII / DS"} - # Muzyka - - {id: 38, cat: Audio/Foreign, desc: "Muzyka Zagraniczna"} - - {id: 39, cat: Audio, desc: "Soundtracki"} - - {id: 40, cat: Audio/Video, desc: "Teledyski"} - - {id: 80, cat: Audio, desc: "Muzyka Polska"} - - {id: 81, cat: Audio/Video, desc: "Koncerty"} - # GSM/PDA - - {id: 42, cat: PC/Mobile-Other, desc: "Tapety GSM/PDA"} - - {id: 43, cat: PC/Mobile-Other, desc: "Programy GSM/PDA"} - - {id: 44, cat: PC/Mobile-Other, desc: "Filmy GSM/PDA"} - - {id: 45, cat: PC/Mobile-Other, desc: "Dzwonki GSM/PDA"} - - {id: 46, cat: PC/Mobile-Other, desc: "Gry GSM/PDA"} - # Ksiazki - - {id: 47, cat: Books/EBook, desc: "E-Booki"} - - {id: 48, cat: Audio/Audiobook, desc: "Audio-Booki"} - - {id: 49, cat: Books/Mags, desc: "Czasopisma"} - - {id: 50, cat: Books/Comics, desc: "Komiksy"} - # Programy - - {id: 51, cat: PC/0day, desc: "Windows"} - - {id: 52, cat: PC, desc: "Linux"} - - {id: 53, cat: PC/Mac, desc: "Macintosh"} - # Erotyka - - {id: 58, cat: XXX/Other, desc: "Czasopisma"} - - {id: 59, cat: XXX/ImageSet, desc: "Zdjęcia"} - - {id: 60, cat: XXX/x264, desc: "Filmy"} - - {id: 61, cat: XXX/Other, desc: "Gry"} - - {id: 63, cat: XXX/Other, desc: "Hentai+18"} - # Anime - - {id: 69, cat: TV/Anime, desc: "Anime"} - - {id: 70, cat: XXX/Other, desc: "Hentai"} - - {id: 121, cat: Books/Comics, desc: "Manga"} - # Inne - - {id: 55, cat: Other, desc: "Tapety"} - - {id: 57, cat: Other, desc: "Pozostale"} - - {id: 86, cat: Other, desc: "Śmieszne"} - - {id: 105, cat: Other, desc: "Design www"} - - {id: 106, cat: Other, desc: "Filmy (S)VCD"} - - {id: 107, cat: Other, desc: "Flash/Shockwave"} - - {id: 108, cat: Other, desc: "Fotografia"} - - {id: 109, cat: Other, desc: "Kursy"} - - {id: 110, cat: Other, desc: "Modelarstwo"} - # Sport - - {id: 92, cat: TV/Sport, desc: "Boks"} - - {id: 93, cat: TV/Sport, desc: "Hokej"} - - {id: 94, cat: TV/Sport, desc: "Koszykówka"} - - {id: 95, cat: TV/Sport, desc: "Kulturystyka i fitness"} - - {id: 96, cat: TV/Sport, desc: "MMA"} - - {id: 97, cat: TV/Sport, desc: "Motorowe"} - - {id: 98, cat: TV/Sport, desc: "Piłka nożna"} - - {id: 99, cat: TV/Sport, desc: "Rowerowe"} - - {id: 100, cat: TV/Sport, desc: "Siatkówka"} - - {id: 101, cat: TV/Sport, desc: "Wodne"} - - {id: 102, cat: TV/Sport, desc: "Wrestling"} - - {id: 103, cat: TV/Sport, desc: "Zapasy"} - - {id: 104, cat: TV/Sport, desc: "Zimowe"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - seed: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_limit - type: info - label: "Download Daily Limit" - default: "Polskie-Torrenty has a daily limit on downloads. If downloads fail then you have exceeded your daily limit. Try again tomorrow." - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://polskie-torrenty.eu/wyszukaj.php?q=Ronin&category=72&sort=seed_desc - - path: wyszukaj.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - q: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - sort: "{{ .Config.sort }}_{{ .Config.type }}" - - rows: - selector: table.results-table tbody tr - - fields: - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: querystring - args: sub_category - title_phase1: - selector: td:nth-child(2) a - title_multilang: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: td:nth-child(2) a - attribute: href - download: - selector: td:nth-child(2) a - attribute: href - filters: - - name: replace - args: ["details.php?id=", "download.php?id="] - date: - text: now - size: - selector: td:nth-child(3) - seeders: - selector: td:nth-child(4) - leechers: - selector: td:nth-child(5) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornbay.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornbay.yml deleted file mode 100644 index 928ad8eb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornbay.yml +++ /dev/null @@ -1,172 +0,0 @@ ---- -id: pornbay -name: Pornbay -description: "Pornbay is a Private Torrent Tracker for 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://pornbay.org/ - -caps: - categorymappings: - - {id: 1, cat: XXX, desc: "Amateur"} - - {id: 2, cat: XXX, desc: "Anal"} - - {id: 3, cat: XXX, desc: "Asian"} - - {id: 4, cat: XXX, desc: "BBW"} - - {id: 31, cat: XXX, desc: "BDSM"} - - {id: 5, cat: XXX, desc: "Black"} - - {id: 6, cat: XXX, desc: "Blowjob"} - - {id: 7, cat: XXX, desc: "Busty"} - - {id: 32, cat: XXX, desc: "Classic"} - - {id: 8, cat: XXX, desc: "Clips"} - - {id: 9, cat: XXX, desc: "Creampie"} - - {id: 10, cat: XXX, desc: "DVD-R"} - - {id: 11, cat: XXX, desc: "Feature"} - - {id: 12, cat: XXX, desc: "Fetish"} - - {id: 13, cat: XXX, desc: "Foreign"} - - {id: 14, cat: XXX, desc: "Game"} - - {id: 16, cat: XXX, desc: "Gonzo"} - - {id: 17, cat: XXX, desc: "Hardcore"} - - {id: 18, cat: XXX, desc: "HD Quality"} - - {id: 19, cat: XXX, desc: "Hentai"} - - {id: 30, cat: XXX, desc: "Homemade"} - - {id: 20, cat: XXX, desc: "Interracial"} - - {id: 21, cat: XXX, desc: "Latin"} - - {id: 22, cat: XXX, desc: "Lesbian"} - - {id: 23, cat: XXX, desc: "Mature"} - - {id: 47, cat: XXX, desc: "Megapack"} - - {id: 49, cat: XXX, desc: "Old+Young"} - - {id: 24, cat: XXX, desc: "Orgy"} - - {id: 25, cat: XXX, desc: "Other"} - - {id: 26, cat: XXX, desc: "Pics"} - - {id: 50, cat: XXX, desc: "POV"} - - {id: 27, cat: XXX, desc: "Sick"} - - {id: 35, cat: XXX, desc: "Site rips"} - - {id: 48, cat: XXX, desc: "Solo"} - - {id: 28, cat: XXX, desc: "Straight"} - - {id: 29, cat: XXX, desc: "Teen"} - - {id: 51, cat: XXX, desc: "VR Porn"} - - {id: 33, cat: XXX, desc: "Transsexual"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_grid - type: info - label: GRID view - default: This indexer does not support the GRID view on the torrent search page. Change the Grid View setting to OFF on your User Settings. If set to ON will cause no results to be returned. - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page setting to 100 on your User Settings. The default is 25. - -login: - path: login.php - method: form - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeplogged: 1 - error: - - selector: form#loginform > span.warning - test: - path: torrents.php - selector: "#nav_useredit" - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - searchtext: "" - title: "{{ .Keywords }}" - order_by: time - order_way: desc - action: advanced - filter_freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - filelist: "" - taglist: "" - - rows: - # exclude redbar torrents - selector: table#torrent_table > tbody > tr[class^="torrent row"] - - fields: - download: - selector: a[href^="torrents.php?action=download&id="] - attribute: href - description: - selector: div.tags - title: - selector: a[href^="torrents.php?id="] - category: - selector: a[href*="filter_cat"] - attribute: href - filters: - - name: regexp - args: "\\[(\\d+?)\\]" - details: - selector: a[href^="torrents.php?id="] - attribute: href - poster: - selector: td:nth-child(2) > script - filters: - - name: regexp - args: "src=\\\\\"(.*?)\\\\\"" - - name: re_replace - args: ["\\\\(.)", "$1"] - - name: replace - args: ["/static/common/noartwork/noimage.png", ""] - files: - selector: td:nth-child(3) - date_elapsed: - # time type: time elapsed - # 1 week ago - selector: span.time:contains("ago") - attribute: title - optional: true - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM dd yyyy, HH:mm zzz" - date_added: - # time added - # May 08 2025, 01:05 - selector: span.time:not(:contains("ago")) - optional: true - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM dd yyyy, HH:mm zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) - leechers: - selector: td:nth-child(9) - downloadvolumefactor: - case: - span.icon[title*="Freeleech"]: 0 - img[alt="Freeleech"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# Gazelle diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornolab.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornolab.yml deleted file mode 100644 index 6a50f360..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornolab.yml +++ /dev/null @@ -1,268 +0,0 @@ ---- -id: pornolab -name: PornoLab -description: "PornoLab is a Semi-Private Russian site for 3X" -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - https://pornolab.net/ - -caps: - categorymappings: - - {id: 1670, cat: XXX, desc: "Эротическое видео / Erotic & Softcore"} - - {id: 1768, cat: XXX, desc: "Эротические фильмы / Erotic Movies"} - - {id: 60, cat: XXX, desc: "Документальные фильмы / Documentary & Reality"} - - {id: 1671, cat: XXX, desc: "Эротические ролики и сайтрипы / Erotic Clips & SiteRips"} - - {id: 1644, cat: XXX, desc: "Нудизм-Натуризм / Nudity"} - - - {id: 1672, cat: XXX, desc: "Зарубежные порнофильмы / Full Length Movies"} - - {id: 1111, cat: XXX/Pack, desc: "Паки полных фильмов / Full Length Movies Packs"} - - {id: 508, cat: XXX, desc: "Классические фильмы / Classic"} - - {id: 555, cat: XXX, desc: "Фильмы с сюжетом / Feature & Vignettes"} - - {id: 1845, cat: XXX, desc: "Гонзо-фильмы 1991-2010 / Gonzo 1991-2010"} - - {id: 1673, cat: XXX, desc: "Гонзо-фильмы 2011-2026 / Gonzo 2011-2026"} - - {id: 1112, cat: XXX, desc: "Фильмы без сюжета 1991-2010 / All Sex & Amateur 1991-2010"} - - {id: 1718, cat: XXX, desc: "Фильмы без сюжета 2011-2026 / All Sex & Amateur 2011-2026"} - - {id: 553, cat: XXX, desc: "Лесбо-фильмы / All Girl & Solo"} - - {id: 1143, cat: XXX, desc: "Этнические фильмы / Ethnic-Themed"} - - {id: 1646, cat: XXX, desc: "Видео для телефонов и КПК / Pocket РС & Phone Video"} - - - {id: 1717, cat: XXX, desc: "Зарубежные фильмы в высоком качестве (DVD&HD) / Full Length Movies High-Quality"} - - {id: 1851, cat: XXX/DVD, desc: "Эротические и Документальные видео (DVD) / Erotic, Documentary & Reality (DVD)"} - - {id: 1713, cat: XXX/DVD, desc: "Фильмы с сюжетом, Классические (DVD) / Feature & Vignettes, Classic (DVD)"} - - {id: 512, cat: XXX/DVD, desc: "Гонзо, Лесбо и Фильмы без сюжета (DVD) / Gonzo, All Girl & Solo, All Sex (DVD)"} - - {id: 1712, cat: XXX, desc: "Эротические и Документальные видео (HD Video) / Erotic, Documentary & Reality (HD Video)"} - - {id: 1775, cat: XXX, desc: "Фильмы с сюжетом, Классические (HD Video) / Feature & Vignettes, Classic (HD Video)"} - - {id: 1450, cat: XXX, desc: "Гонзо, Лесбо и Фильмы без сюжета (HD Video) / Gonzo, All Girl & Solo, All Sex (HD Video)"} - - - {id: 1674, cat: XXX, desc: "Русское порно / Russian Video"} - - {id: 902, cat: XXX, desc: "Русские порнофильмы / Russian Full Length Movies"} - - {id: 1675, cat: XXX/Pack, desc: "Паки русских порнороликов / Russian Clips Packs"} - - {id: 36, cat: XXX, desc: "Сайтрипы с русскими актрисами 1991-2015 / Russian SiteRip's 1991-2015"} - - {id: 1830, cat: XXX, desc: "Сайтрипы с русскими актрисами 1991-2015 (HD Video) / Russian SiteRip's 1991-2015 (HD Video)"} - - {id: 1803, cat: XXX, desc: "Сайтрипы с русскими актрисами 2016-2025 / Russian SiteRip's 2016-2025"} - - {id: 1831, cat: XXX, desc: "Сайтрипы с русскими актрисами 2016-2025 (HD Video) / Russian SiteRip's 2016-2025 (HD Video)"} - - {id: 1877, cat: XXX, desc: "Сайтрипы с русскими актрисами 2026 / Russian SiteRip's 2026"} - - {id: 1878, cat: XXX, desc: "Сайтрипы с русскими актрисами 2026 (HD Video) / Russian SiteRip's 2026 (HD Video)"} - - {id: 1741, cat: XXX, desc: "Русские Порноролики Разное / Russian Clips (various)"} - - {id: 1676, cat: XXX, desc: "Русское любительское видео / Russian Amateur Video"} - - - {id: 1677, cat: XXX, desc: "Зарубежные порноролики / Clips"} - - {id: 1780, cat: XXX/Pack, desc: "Паки сайтрипов (HD Video) / SiteRip's Packs (HD Video)"} - - {id: 1110, cat: XXX/Pack, desc: "Паки сайтрипов (SD Video) / SiteRip's Packs (SD Video)"} - - {id: 1678, cat: XXX/Pack, desc: "Паки порнороликов по актрисам / Actresses Clips Packs"} - - {id: 1124, cat: XXX, desc: "Сайтрипы 1991-2010 (HD Video) / SiteRip's 1991-2010 (HD Video)"} - - {id: 1784, cat: XXX, desc: "Сайтрипы 2011-2012 (HD Video) / SiteRip's 2011-2012 (HD Video)"} - - {id: 1769, cat: XXX, desc: "Сайтрипы 2013 (HD Video) / SiteRip's 2013 (HD Video)"} - - {id: 1793, cat: XXX, desc: "Сайтрипы 2014 (HD Video) / SiteRip's 2014 (HD Video)"} - - {id: 1797, cat: XXX, desc: "Сайтрипы 2015 (HD Video) / SiteRip's 2015 (HD Video)"} - - {id: 1804, cat: XXX, desc: "Сайтрипы 2016 (HD Video) / SiteRip's 2016 (HD Video)"} - - {id: 1819, cat: XXX, desc: "Сайтрипы 2017 (HD Video) / SiteRip's 2017 (HD Video)"} - - {id: 1825, cat: XXX, desc: "Сайтрипы 2018 (HD Video) / SiteRip's 2018 (HD Video)"} - - {id: 1836, cat: XXX, desc: "Сайтрипы 2019 (HD Video) / SiteRip's 2019 (HD Video)"} - - {id: 1842, cat: XXX, desc: "Сайтрипы 2020 (HD Video) / SiteRip's 2020 (HD Video)"} - - {id: 1846, cat: XXX, desc: "Сайтрипы 2021 (HD Video) / SiteRip's 2021 (HD Video)"} - - {id: 1857, cat: XXX, desc: "Сайтрипы 2022 (HD Video) / SiteRip's 2022 (HD Video)"} - - {id: 1861, cat: XXX, desc: "Сайтрипы 2023 (HD Video) / SiteRip's 2023 (HD Video)"} - - {id: 1867, cat: XXX, desc: "Сайтрипы 2024 (HD Video) / SiteRip's 2024 (HD Video)"} - - {id: 1872, cat: XXX, desc: "Сайтрипы 2025 (HD Video) / SiteRip's 2025 (HD Video)"} - - {id: 1875, cat: XXX, desc: "Сайтрипы 2026 (HD Video) / SiteRip's 2026 (HD Video)"} - - {id: 1451, cat: XXX, desc: "Сайтрипы 1991-2010 / SiteRip's 1991-2010"} - - {id: 1788, cat: XXX, desc: "Сайтрипы 2011-2012 / SiteRip's 2011-2012"} - - {id: 1789, cat: XXX, desc: "Сайтрипы 2013 / SiteRip's 2013"} - - {id: 1792, cat: XXX, desc: "Сайтрипы 2014 / SiteRip's 2014"} - - {id: 1798, cat: XXX, desc: "Сайтрипы 2015 / SiteRip's 2015"} - - {id: 1805, cat: XXX, desc: "Сайтрипы 2016 / SiteRip's 2016"} - - {id: 1820, cat: XXX, desc: "Сайтрипы 2017 / SiteRip's 2017"} - - {id: 1826, cat: XXX, desc: "Сайтрипы 2018 / SiteRip's 2018"} - - {id: 1837, cat: XXX, desc: "Сайтрипы 2019 / SiteRip's 2019"} - - {id: 1843, cat: XXX, desc: "Сайтрипы 2020 / SiteRip's 2020"} - - {id: 1847, cat: XXX, desc: "Сайтрипы 2021 / SiteRip's 2021"} - - {id: 1856, cat: XXX, desc: "Сайтрипы 2022 / SiteRip's 2022"} - - {id: 1862, cat: XXX, desc: "Сайтрипы 2023 / SiteRip's 2023"} - - {id: 1868, cat: XXX, desc: "Сайтрипы 2024 / SiteRip's 2024"} - - {id: 1873, cat: XXX, desc: "Сайтрипы 2025 / SiteRip's 2025"} - - {id: 1876, cat: XXX, desc: "Сайтрипы 2026 / SiteRip's 2026"} - - {id: 1707, cat: XXX, desc: "Сцены из фильмов / Movie Scenes (кроме SiteRip)"} - - {id: 1874, cat: XXX, desc: "Порноролики с переводом / Clips with translation"} - - {id: 284, cat: XXX, desc: "Порноролики Разное / Clips (various)"} - - {id: 1853, cat: XXX, desc: "Компиляции и Музыкальные порно клипы / Compilations & Porn Music Video (PMV)"} - - {id: 1823, cat: XXX, desc: "Порноролики в 3D и Virtual Reality (VR) / 3D & Virtual Reality Videos"} - - - {id: 1800, cat: XXX, desc: "Японское и китайское порно / Japanese & Chinese Adult Video (JAV)"} - - {id: 1801, cat: XXX/Pack, desc: "Паки японских фильмов и сайтрипов / Full Length Japanese Movies Packs & SiteRip's Packs"} - - {id: 1719, cat: XXX, desc: "Японские фильмы и сайтрипы (DVD и HD Video) / Japanese Movies & SiteRip's (DVD & HD Video)"} - - {id: 997, cat: XXX, desc: "Японские фильмы и сайтрипы 1991-2014 / Japanese Movies & SiteRip's 1991-2014"} - - {id: 1818, cat: XXX, desc: "Японские фильмы и сайтрипы 2015-2026 / Japanese Movies & SiteRip's 2015-2026"} - - {id: 1849, cat: XXX, desc: "Китайские фильмы и сайтрипы (DVD и HD Video) / Chinese Movies & SiteRip's (DVD & HD Video)"} - - - {id: 1723, cat: XXX, desc: "Фото и журналы / Photos & Magazines"} - - {id: 1726, cat: XXX, desc: "MetArt & MetModels"} - - {id: 883, cat: XXX/ImageSet, desc: "Эротические студии Разное / Erotic Picture Gallery (various)"} - - {id: 1728, cat: XXX/ImageSet, desc: "Любительское фото / Amateur Picture Gallery"} - - {id: 1729, cat: XXX/Pack, desc: "Подборки по актрисам / Actresses Picture Packs"} - - {id: 38, cat: XXX/ImageSet, desc: "Подборки сайтрипов / SiteRip's Picture Packs"} - - {id: 1757, cat: XXX/ImageSet, desc: "Подборки сетов / Picture Sets Packs"} - - {id: 1735, cat: XXX/ImageSet, desc: "Тематическое и нетрадиционное фото / Misc & Special Interest Picture Packs"} - - {id: 1731, cat: XXX/ImageSet, desc: "Журналы / Magazines"} - - - {id: 1745, cat: XXX, desc: "Хентай и Манга, Мультфильмы и Комиксы, Рисунки, Аудио / Hentai & Manga, Cartoons & Comics, Artwork & Audio"} - - {id: 1679, cat: XXX, desc: "Хентай: основной подраздел / Hentai: main subsection"} - - {id: 1740, cat: XXX, desc: "Хентай в высоком качестве (DVD и HD) / Hentai DVD & HD"} - - {id: 1834, cat: XXX, desc: "Хентай: ролики 2D / Hentai: 2D video"} - - {id: 1752, cat: XXX, desc: "Хентай: ролики 3D / Hentai: 3D video"} - - {id: 1760, cat: XXX, desc: "Хентай: Манга / Hentai: Manga"} - - {id: 1781, cat: XXX, desc: "Хентай: Арт и HCG / Hentai: Artwork & HCG"} - - {id: 1711, cat: XXX, desc: "Мультфильмы / Cartoons"} - - {id: 1296, cat: XXX, desc: "Комиксы, рисунки, аудио / Comics, Artwork & Audio"} - - {id: 1863, cat: XXX, desc: "Обсуждение / Discussion"} - - - {id: 1838, cat: XXX, desc: "Игры / Games"} - - {id: 1750, cat: XXX, desc: "Игры: основной подраздел / Games: main subsection"} - - {id: 1756, cat: XXX, desc: "Игры: визуальные новеллы (профессиональная студия) / Games: Visual Novels (Pro)"} - - {id: 1869, cat: XXX, desc: "Игры: визуальные новеллы (независимый разработчик) / Games: Visual Novels (Indie)"} - - {id: 1785, cat: XXX, desc: "Игры: ролевые / Games: role-playing [RPG Maker / WOLF RPG Editor]"} - - {id: 1790, cat: XXX, desc: "Игры и Софт: Анимация / Software: Animation"} - - {id: 1827, cat: XXX, desc: "Игры: В разработке и Демо (основной подраздел) / Games: In Progress and Demo (main subsection)"} - - {id: 1870, cat: XXX, desc: "Игры: В разработке и Демо (визуальные новеллы - независимый разработчик) / Games: In Progress and Demo (visual novels - Indie)"} - - {id: 1828, cat: XXX, desc: "Игры: В разработке и Демо (ролевые) / Games: In Progress and Demo (role-playing) [RPG Maker / WOLF RPG Editor]"} - - {id: 1829, cat: XXX, desc: "Обсуждение игр / Games Discussion"} - - - {id: 11, cat: XXX, desc: "Нетрадиционное порно / Special Interest Movies & Clips"} - - {id: 1715, cat: XXX, desc: "Транссексуалы (DVD и HD) / Transsexual (DVD & HD)"} - - {id: 1680, cat: XXX, desc: "Транссексуалы / Transsexual"} - - {id: 1758, cat: XXX, desc: "Бисексуалы / Bisexual"} - - {id: 1682, cat: XXX, desc: "БДСМ / BDSM"} - - {id: 1733, cat: XXX, desc: "Женское доминирование и страпон / Femdom & Strapon"} - - {id: 1754, cat: XXX, desc: "Подглядывание / Voyeur"} - - {id: 1734, cat: XXX, desc: "Фистинг и дилдо / Fisting & Dildo"} - - {id: 1791, cat: XXX, desc: "Беременные / Pregnant"} - - {id: 509, cat: XXX, desc: "Буккаке / Bukkake"} - - {id: 1859, cat: XXX, desc: "Гэнг-бэнг / GangBang"} - - {id: 1685, cat: XXX, desc: "Мочеиспускание / Peeing"} - - {id: 1762, cat: XXX, desc: "Фетиш / Fetish"} - - {id: 1681, cat: XXX, desc: "Дефекация / Scat"} - - - {id: 1688, cat: XXX, desc: "Гей-порно / Gay Forum"} - - {id: 903, cat: XXX, desc: "Полнометражные гей-фильмы / Full Length Movies (Gay)"} - - {id: 1765, cat: XXX, desc: "Полнометражные азиатские гей-фильмы / Full-length Asian Films (Gay)"} - - {id: 1767, cat: XXX, desc: "Классические гей-фильмы (до 1990 года) / Classic Gay Films (Pre-1990's)"} - - {id: 1755, cat: XXX, desc: "Гей-фильмы в высоком качестве (DVD и HD) / High-Quality Full Length Movies (Gay DVD & HD)"} - - {id: 1787, cat: XXX, desc: "Азиатские гей-фильмы в высоком качестве (DVD и HD) / High-Quality Full Length Asian Movies (Gay DVD & HD)"} - - {id: 1763, cat: XXX/Pack, desc: "ПАКи гей-роликов и сайтрипов / Clip's & SiteRip's Packs (Gay)"} - - {id: 1777, cat: XXX, desc: "Гей-ролики в высоком качестве (HD Video) / Gay Clips (HD Video)"} - - {id: 1691, cat: XXX, desc: "Ролики, SiteRip'ы и сцены из гей-фильмов / Clips & Movie Scenes (Gay)"} - - {id: 1692, cat: XXX/ImageSet, desc: "Гей-журналы, фото, разное / Magazines, Photo, Rest (Gay)"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: forum/login.php - method: form - form: form[action="/forum/login.php"] - captcha: - type: image - selector: img[src^="//static.pornolab.net/captcha/"] - input: input[name^="cap_code_"] - inputs: - input[name="login_username"]: "{{ .Config.username }}" - input[name="login_password"]: "{{ .Config.password }}" - selectors: true - selectorinputs: - cap_sid: - selector: input[name="cap_sid"] - attribute: value - optional: true - error: - - selector: div:contains("Форум временно отключен") - - selector: h4.warnColor1.tCenter.mrg_16 - test: - path: forum/index.php - selector: div.topmenu a[onclick*=logout] - -search: - paths: - - path: forum/tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - nm: "{{ .Keywords }}" - - keywordsfilters: - - name: re_replace - args: ["\\W+", " "] - - name: trim - - rows: - selector: table#tor-tbl > tbody > tr:has(a.tr-dl) - - fields: - category: - selector: a.f - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.tLink - filters: - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: trim - details: - selector: a.tLink - attribute: href - download: - selector: a.tr-dl - attribute: href - size: - selector: td:nth-child(6) u - date: - # unix - selector: td:nth-child(11) u - seeders: - selector: td.seedmed > b - optional: true - default: 0 - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.31 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornotorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornotorrent.yml deleted file mode 100644 index 65c314d2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornotorrent.yml +++ /dev/null @@ -1,70 +0,0 @@ ---- -id: pornotorrent -name: PornoTorrent -description: "PornoTorrent is a BRAZILIAN Public Torrent Tracker for 3X" -language: pt-BR -type: public -encoding: UTF-8 -testlinktorrent: false -links: - - https://pornotorrent.com.br/ -legacylinks: - - https://www.pornotorrent.eu/ - - https://pornotorrent.net.br/ - -caps: - categorymappings: - - {id: XXX, cat: XXX, desc: XXX} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: script:contains("CM_MAGNET_URL") - filters: - - name: regexp - args: /magnet./(.+?)\" - - name: urldecode - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - cat: 0 - - rows: - selector: div.section_inner > div.row > div - - fields: - category: - text: XXX - title: - selector: a.cm-card-pretitle__badge - details: - selector: a.cm-card-pretitle__badge - attribute: href - download: - selector: a.cm-card-pretitle__badge - attribute: href - poster: - selector: img - attribute: src - date: - text: now - size: - text: 5GB - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: span:has(span.cm-ico-clock) -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornrips.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornrips.yml deleted file mode 100644 index 4e7acebd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pornrips.yml +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: pornrips -name: PornRips -description: "PornRips is a Public site for 3X" -language: en-US -type: public -encoding: UTF-8 -links: - - https://pornrips.to/ - -caps: - categories: - XXX: XXX - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[href*="/torrents/"] - attribute: href - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - - rows: - selector: article:has(a) - filters: - - name: andmatch - - fields: - category: - text: XXX - title: - selector: .entry-title - details: - selector: .entry-title > a - attribute: href - download: - selector: .entry-title > a - attribute: href - poster: - selector: img - attribute: data-src - date: - selector: time - attribute: datetime - size: - selector: p - filters: - - name: regexp - args: (\d+ [M|G]i?B) - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# wordpress 6.6 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/portugas-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/portugas-api.yml deleted file mode 100644 index 98ed6af0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/portugas-api.yml +++ /dev/null @@ -1,209 +0,0 @@ ---- -id: portugas-api -name: Portugas (API) -description: "Portugas is a PORTUGUESE Private Torrent Tracker for 0DAY / GENERAL" -language: pt-PT -type: private -encoding: UTF-8 -links: - - https://portugas.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmes"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Músicas"} - - {id: 4, cat: PC/Games, desc: "Jogos"} - - {id: 5, cat: PC/0day, desc: "Appz"} - - {id: 7, cat: XXX, desc: "XXX"} - - {id: 9, cat: Books, desc: "E-Books"} - - {id: 10, cat: Movies, desc: "Animação Filmes"} - - {id: 11, cat: TV/Anime, desc: "Animação TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Portugas account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "All users must log into their account at least once every 60 days, otherwise the account will be deactivated." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - _audiopt: - selector: audio_pt - case: - False: "{{ .False }}" - True: "{{ .True }}" - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}{{ if .Result._audiopt }} Portuguese PT-PT{{ else }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)^None$", ""] - - name: re_replace - args: ["(?i)(Ficção científica)", "Ficção_científica"] - - name: re_replace - args: ["(?i)(Cinema TV)", "Cinema_TV"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 8.3.6 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/postman-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/postman-api.yml deleted file mode 100644 index b0668da1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/postman-api.yml +++ /dev/null @@ -1,131 +0,0 @@ ---- -id: postman-api -name: Postman (API) -description: "Postman is a Semi-Private I2P Torrent Tracker for MOVIES / TV / GENERAL. This indexer uses the API for login." -language: en-US -type: semi-private -encoding: UTF-8 -requestDelay: 6 # rate limit: 10 req/min -links: - - http://tracker2.postman.i2p/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: Audio, desc: "Music"} - - {id: 3, cat: TV, desc: "TV"} - - {id: 4, cat: PC/Games, desc: "Games"} - - {id: 5, cat: PC/0day, desc: "Apps"} - - {id: 6, cat: Other/Misc, desc: "Misc."} - - {id: 8, cat: TV, desc: "Pictures"} - - {id: 9, cat: TV/Anime, desc: "Anime"} - - {id: 10, cat: Books/Comics, desc: "Comics"} - - {id: 24, cat: Audio/Other, desc: "Podcasts"} - - {id: 11, cat: Books, desc: "Books"} - - {id: 17, cat: Audio/Audiobook, desc: "Audio Books"} - - {id: 20, cat: Books/EBook, desc: "E-Books"} - - {id: 21, cat: Other, desc: "Course/Lesson"} - - {id: 22, cat: Other, desc: "Essay/Op-Ed"} - - {id: 23, cat: Other, desc: "Cad/3D Printing"} - - {id: 13, cat: Audio/Video, desc: "Music Vid."} - - {id: 14, cat: XXX, desc: "Pr0n"} - - {id: 15, cat: TV/Documentary, desc: "Documentary"} - - {id: 16, cat: Other, desc: "Leaked Documents"} - - {id: 18, cat: Other, desc: "Conspiracy"} - - {id: 19, cat: Other, desc: "Religious Content"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Postman account Preferences page." - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 5: seeders - 7: size - -login: - path: api/meta/session - method: get - error: - - selector: :root:contains("403") - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("429"):contains("daily quota") - message: - text: "You have exceeded your daily quota, you have to wait until 00:00 UTC next day." - - selector: :root:contains("429"):contains("rate limit") - message: - text: "API is temporary unavailable due to rate limiting. Try going slower please!" - -search: - paths: - - path: api/torrents - response: - type: json - - inputs: - name: "{{ .Keywords }}" - sort_id: "{{ .Config.sort }}" - filter_id: -1 - catfilter_id: -1 - entries: 100 - - headers: - X-Postman-Token: ["{{ .Config.apikey }}"] - - rows: - selector: torrents - count: - selector: available_entries - - fields: - category: - selector: category_id - title: - selector: name - download_link: - selector: download_link - download: - text: "/{{ .Result.download_link }}" - details: - text: "{{ .Result.download }}" - filters: - - name: replace - args: ["action=Download", "view=TorrentDetail"] - magnet: - selector: magnet_link - date: - selector: added - filters: - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss" - files: - selector: file_count - size: - selector: size - grabs: - selector: times_completed - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Postman Tracker JSON API v0.86 build 2025081002 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/postman.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/postman.yml deleted file mode 100644 index 40b45fc4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/postman.yml +++ /dev/null @@ -1,140 +0,0 @@ ---- -id: postman -name: Postman -description: Postman is a Public I2P Torrent Tracker for MOVIES / TV / GENERAL -language: en-US -type: public -encoding: UTF-8 -requestDelay: 4 -links: - - http://tracker2.postman.i2p/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: Audio, desc: "Music"} - - {id: 3, cat: TV, desc: "TV"} - - {id: 4, cat: PC/Games, desc: "Games"} - - {id: 5, cat: PC/0day, desc: "Apps"} - - {id: 6, cat: Other/Misc, desc: "Misc."} - - {id: 8, cat: TV, desc: "Pictures"} - - {id: 9, cat: TV/Anime, desc: "Anime"} - - {id: 10, cat: Books/Comics, desc: "Comics"} - - {id: 24, cat: Audio/Other, desc: "Podcasts"} - - {id: 11, cat: Books, desc: "Books"} - - {id: 17, cat: Audio/Audiobook, desc: "Audio Books"} - - {id: 20, cat: Books/EBook, desc: "E-Books"} - - {id: 21, cat: Other, desc: "Course/Lesson"} - - {id: 22, cat: Other, desc: "Essay/Op-Ed"} - - {id: 23, cat: Other, desc: "Cad/3D Printing"} - - {id: 13, cat: Audio/Video, desc: "Music Vid."} - - {id: 14, cat: XXX, desc: "Pr0n"} - - {id: 15, cat: TV/Documentary, desc: "Documentary"} - - {id: 16, cat: Other, desc: "Leaked Documents"} - - {id: 18, cat: Other, desc: "Conspiracy"} - - {id: 19, cat: Other, desc: "Religious Content"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 5: seeders - 7: size - -login: - path: index.php?view=Main - selectorinputs: - formtoken: - selector: input[name="formtoken"] - attribute: value - test: - path: / - selector: a[href^="index.php?view=Login"] - -search: - paths: - - path: index.php - allowEmptyInputs: true - inputs: - view: Main - search: "{{ .Keywords }}" - category: -1 - orderby: "{{ .Config.sort }}" - lastactive: -1 - lang: -1 - - rows: - selector: table#torrentView tbody tr - - fields: - category: - selector: td.category a - attribute: href - filters: - - name: querystring - args: category - title: - selector: td.torrentname a - attribute: title - details: - selector: td.torrentname a - attribute: href - download: - selector: a[href^="index.php?action=Download&id="] - attribute: href - magnet: - selector: a[href^="magnet:?xt"] - attribute: href - description: - selector: td.torrentname span - imdbid: - text: "{{ .Result.description }}" - filters: - - name: regexp - args: "(https?:\\/\\/www\\.imdb\\.com\\/title\\/tt\\d+)" - tmdbid: - text: "{{ .Result.description }}" - filters: - - name: regexp - args: "(https?:\\/\\/www\\.themoviedb\\.org\\/(movie|tv)\\/.+?)\\n" - date: - selector: td.added - filters: - - name: dateparse - args: "yyyy-MM-dd" - files: - selector: span.filecount - size: - selector: span.size - remove: span - filters: - - name: append - args: B - grabs: - selector: span.badge.downloadcount - seeders: - selector: span.swarm - filters: - - name: split - args: ["/", 0] - leechers: - selector: span.swarm - remove: span - filters: - - name: split - args: ["/", 1] - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# PaTracker 1.7.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/proaudiotorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/proaudiotorrents.yml deleted file mode 100644 index 7cabb677..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/proaudiotorrents.yml +++ /dev/null @@ -1,138 +0,0 @@ ---- -id: proaudiotorrents -name: ProAudioTorrents -description: "ProAudioTorrents (PAT) is a Private Torrent Tracker for AUDIO SOFTWARE / SAMPLES / TUTORIALS / ETC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://proaudiotorrents.org/ -legacylinks: - - http://proaudiotorrents.org/ - -caps: - categorymappings: - - {id: 24, cat: PC/0day, desc: "DJ Applications"} - - {id: 3, cat: Books/EBook, desc: "Ebooks"} - - {id: 4, cat: PC, desc: "Expansions & Addons"} - - {id: 5, cat: PC, desc: "HYBRID PC/MAC"} - - {id: 26, cat: Other, desc: "Independent"} - - {id: 19, cat: PC, desc: "Linux Plugins"} - - {id: 2, cat: PC/Mac, desc: "Mac Audio Apps"} - - {id: 15, cat: PC/Mac, desc: "Mac Updates"} - - {id: 22, cat: PC/Mac, desc: "Mac VST Fx"} - - {id: 21, cat: PC/Mac, desc: "Mac VST Instrument"} - - {id: 6, cat: Other/Misc, desc: "Misc / Other"} - - {id: 20, cat: TV/Documentary, desc: "Music Documentary"} - - {id: 18, cat: TV/Documentary, desc: "Musical Training"} - - {id: 1, cat: PC/0day, desc: "PC Audio Apps"} - - {id: 14, cat: PC/0day, desc: "PC Updates"} - - {id: 16, cat: Other, desc: "Refills"} - - {id: 7, cat: PC, desc: "RTAS"} - - {id: 8, cat: Audio, desc: "Samples / Libraries"} - - {id: 12, cat: Audio, desc: "SFX"} - - {id: 9, cat: TV/Documentary, desc: "Software Tutorials"} - - {id: 11, cat: PC, desc: "VST Instruments"} - - {id: 13, cat: PC, desc: "VST Plug-ins"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: down - options: - down: desc - up: asc - - name: info_activity - type: info - label: How long does my account stay if it is inactive? - default: "12 weeks exactly. You will need to log in before that amount of time has elapsed to keep your account active." - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: my.php - selector: a[href="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - # does not appear to support multi category searches - cat: 0 - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both - searchin: 0 - # note: using incldead=3 causes `Unknown column 'free' in 'where clause'` at web site - # 0 active, 1 incldead, 2 onlydead, 3 freeleech - incldead: 1 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: tr.ttable:has(a[href^="browse.php?cat="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: td:nth-child(2) a - attribute: title - download: - selector: a[href^="download.php/"] - attribute: href - details: - selector: a[href^="details.php?id="] - attribute: href - files: - selector: td:nth-child(3) - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - date: - selector: td:nth-child(2) - remove: a - filters: - - name: replace - args: ["Uploaded ", ""] - - name: timeago - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/proporno.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/proporno.yml deleted file mode 100644 index 28d537f2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/proporno.yml +++ /dev/null @@ -1,136 +0,0 @@ ---- -id: proporno -name: ProPorno -description: "ProPorno is a RUSSIAN Semi-Private tracker for 3X" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://proporno.org/ - -caps: - categorymappings: - - {id: 2, cat: XXX, desc: "порно видео"} - - {id: 4, cat: XXX, desc: "Порно с блондинками"} - - {id: 5, cat: XXX, desc: "лесбиянки"} - - {id: 19, cat: XXX, desc: "эротика"} - - {id: 20, cat: XXX, desc: "груповушка"} - - {id: 16, cat: XXX, desc: "анальный секс"} - - {id: 22, cat: XXX, desc: "Молодые девушки"} - - {id: 24, cat: XXX, desc: "Минет / куннилингус / оральный секс"} - - {id: 25, cat: XXX, desc: "большая грудь"} - - {id: 28, cat: XXX, desc: "порно видео зрелых"} - - {id: 29, cat: XXX, desc: "видео кастинг"} - - {id: 32, cat: XXX, desc: "девушки с красивыми попами"} - - {id: 33, cat: XXX, desc: "порно с мулатками"} - - {id: 21, cat: XXX, desc: "любительское видео"} - - {id: 3, cat: XXX, desc: "порно фильмы"} - - {id: 30, cat: XXX, desc: "зарубежные порно фильмы"} - - {id: 31, cat: XXX, desc: "фильмы с русским переводом"} - - {id: 7, cat: XXX, desc: "русское порно видео"} - - {id: 27, cat: XXX, desc: "убойная эротика"} - - {id: 26, cat: XXX, desc: "онлайн порно"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -login: - path: / - method: form - form: form[onSubmit] - inputs: - login_name: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - login: submit - error: - - selector: div.berrors:contains("Ошибка авторизации") - test: - path: / - selector: a[href$="/index.php?action=logout"] - -search: - paths: - # https://proporno.org/index.php?do=search&subaction=search&story=2024&titleonly=3&sortby=date&resorder=desc&showposts=0 - - path: index.php - inputs: - do: search - subaction: search - story: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - # 0 articles, 1 comments, 2 static, 3 titles - titleonly: "{{ if .Keywords }}3{{ else }}0{{ end }}" - sortby: date - resorder: desc - showposts: 0 - $raw: "{{ range .Categories }}catlist[]={{.}}&{{end}}" - - rows: - selector: div.goods-777:has(div.inmy), div.goods-7777:has(div.mini) - after: 1 - - fields: - categorydesc_optional: - selector: div.inmy > a:last-of-type - optional: true - categorydesc: - text: "{{ if .Result.categorydesc_optional }}{{ .Result.categorydesc_optional }}{{ else }}порно видео{{ end }}" - title: - selector: div.news_text > a - details: - selector: div.news_text > a - attribute: href - download: - selector: div.news_text > a - attribute: href - poster: - selector: img[src*="/uploads/posts/"] - attribute: src - date_day: - # Сегодня, 19:56 - # Вчера, 20:01 - selector: div.mini:contains("Сегодня"), div.mini:contains("Вчера") - optional: true - filters: - - name: regexp - args: "((Вчера|Сегодня)(, \\d{2}:\\d{2}))" - - name: replace - args: ["Вчера", "Yesterday"] - - name: replace - args: ["Сегодня", "Today"] - - name: fuzzytime - date_year: - # 20-09-2020, 19:41 - selector: div.mini:contains("-") - optional: true - filters: - - name: regexp - args: "(\\d{1,2}-\\d{2}-\\d{4}, \\d{2}:\\d{2})" - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "d-MM-yyyy, HH:mm zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - size: - text: "1 GB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# DataLife v24 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptcafe.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptcafe.yml deleted file mode 100644 index b5381f34..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptcafe.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: ptcafe -name: PTCafe (咖啡) -description: "PTCafe (咖啡) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ptcafe.club/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影"} - - {id: 402, cat: TV, desc: "剧集"} - - {id: 403, cat: TV, desc: "综艺"} - - {id: 404, cat: TV/Documentary, desc: "纪录"} - - {id: 405, cat: TV/Anime, desc: "动漫"} - - {id: 406, cat: Audio/Video, desc: "MV"} - - {id: 407, cat: TV/Sport, desc: "体育"} - - {id: 408, cat: Audio, desc: "音乐"} - - {id: 409, cat: Other, desc: "其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the PTCafe Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Retention rules:
  1. [Veteran User] and above will be retained forever.
  2. [Elite User] and above will not be deleted after their accounts are blocked.
  3. Users with a parked account will be banned if they do not log in for 365 consecutive days.
  4. Users with a non-parked account will be banned if they do not log in for 180 consecutive days.
  5. Newly registered accounts will be banned by the system if they have no traffic within 14 days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (not used) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.8 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptcc.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptcc.yml deleted file mode 100644 index 4a3a5155..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptcc.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: ptcc -name: PTCC (我的PT) -description: "PTCC (我的PT) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://cc.mypt.cc/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/剧集"} - - {id: 403, cat: TV, desc: "TV Shows/节目"} - - {id: 404, cat: TV/Documentary, desc: "DOCU/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动画片"} - - {id: 406, cat: Audio/Video, desc: "MTV/演唱会/唱片"} - - {id: 407, cat: TV/Sport, desc: "Sports/运动/体育"} - - {id: 408, cat: TV/Other, desc: "Playlet/短剧"} - - {id: 409, cat: Movies/Other, desc: "Opera/戏曲"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users with no traffic (i.e. upload/download data is 0) will be deleted if they have not logged in for more than 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr (broken so cannot search doubanid), 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pterclub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pterclub.yml deleted file mode 100644 index 30628093..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pterclub.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: pterclub -name: PTerClub (PT之友俱乐部) -description: "PTerClub (PT之友俱乐部) is a CHINESE Private Torrent Tracker for HD MUSIC VIDEOS / MOVIES / TV / ANIME" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pterclub.net/ -legacylinks: - - https://pterclub.com/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影 (Movie)"} - - {id: 404, cat: TV, desc: "电视剧 (TV Play)"} - - {id: 403, cat: TV/Anime, desc: "动漫 (Anime)"} - - {id: 405, cat: TV, desc: "综艺 (TV Show)"} - - {id: 413, cat: Audio/Video, desc: "音乐短片 (MV)"} - - {id: 418, cat: Audio/Video, desc: "舞台演出(Stage Performance)"} - - {id: 406, cat: Audio, desc: "音乐 (Music)"} - - {id: 402, cat: TV/Documentary, desc: "纪录片 (Documentary)"} - - {id: 407, cat: TV/Sport, desc: "体育 (Sport)"} - - {id: 408, cat: Books, desc: "电子书 (Ebook)"} - - {id: 409, cat: Console, desc: "游戏 (Game)"} - - {id: 410, cat: PC, desc: "软件 (Software)"} - - {id: 411, cat: Other, desc: "学习 (Study)"} - - {id: 412, cat: Other, desc: "其它 (Other)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. All accounts will be permanently protected and will not be deleted; however, due to site security, some users will be temporarily banned due to activity rules
  2. New users with no traffic changes will be disabled after 7 days of registration (not the actual download volume)
  3. Those who download free torrents without generating any upload volume, or simply auxiliary torrents that generate magic power without any uploads will still be banned
  4. Users with a non-parked account will be disabled if they do not log in for 60 consecutive days
  5. Users with a parked account will be disabled if they do not log in for 180 consecutive days
  6. Angora/Veteran User and above will not be disabled after parking
  7. Sichuan Jianzhou Cat/Nexus Master users and above will be retained forever.
" - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php?&noredir=1 - selector: a[data-url="logout.php"] - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 3 uploader, 4 imdburl, 5 DoubanID - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}5{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="], a[href^="detailsgame.php?"] - title: - selector: a[title][href^="details.php?id="], a[title][href^="detailsgame.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="], a[href^="detailsgame.php?"] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - selector: a[href$="&search_area=4"] - attribute: href - doubanid: - selector: a[href$="&search_area=5"] - attribute: href - # staff have extra columns at the start and end of a row, so we have to float the next lot of selectors off an anchor - _anchor1: - selector: td:has(a[href^="comment.php?"]) - optional: true - date_elapsed: - # time type: time elapsed (default) - selector: "td:has(a[{{ if .Result._anchor1 }}href^=\"comment.php\"{{ else }}href$=\"startcomments\"{{ end }}]) ~ td > span[title]" - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: "td:has(a[{{ if .Result._anchor1 }}href^=\"comment.php\"{{ else }}href$=\"startcomments\"{{ end }}]) ~ td:not(:has(span[title]))" - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: "td:has(a[{{ if .Result._anchor1 }}href^=\"comment.php\"{{ else }}href$=\"startcomments\"{{ end }}]) ~ td ~ td" - seeders: - selector: "td:has(a[{{ if .Result._anchor1 }}href^=\"comment.php\"{{ else }}href$=\"startcomments\"{{ end }}]) ~ td ~ td ~ td" - leechers: - selector: "td:has(a[{{ if .Result._anchor1 }}href^=\"comment.php\"{{ else }}href$=\"startcomments\"{{ end }}]) ~ td ~ td ~ td ~ td" - grabs: - selector: "td:has(a[{{ if .Result._anchor1 }}href^=\"comment.php\"{{ else }}href$=\"startcomments\"{{ end }}]) ~ td ~ td ~ td ~ td ~ td" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.9 - description: - selector: td:has(table.torrentname) - remove: a, img -# NexusPHP prod#60d2fdc724a 2023-05-10 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptfans.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptfans.yml deleted file mode 100644 index 2205b708..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptfans.yml +++ /dev/null @@ -1,206 +0,0 @@ ---- -id: ptfans -name: PTFans -description: "PTFans is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://ptfans.cc/ -legacylinks: - - https://cusat.win/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "Movies/电影", default: true} - - {id: 404, cat: TV, desc: "TV Series/电视剧", default: true} - - {id: 405, cat: TV, desc: "TV Shows/综艺", default: true} - - {id: 406, cat: TV/Documentary, desc: "Documentaries/纪录片", default: true} - - {id: 403, cat: TV/Sport, desc: "Sport/体育、竞技、武术及相关", default: true} - - {id: 409, cat: PC/Games, desc: "Games/游戏及相关", default: true} - - {id: 407, cat: Audio, desc: "Music/音乐、专辑、MV、演唱会", default: true} - - {id: 408, cat: Other, desc: "Art/曲艺、相声、小品、戏曲、舞蹈、歌剧、评书等", default: true} - - {id: 410, cat: Other, desc: "Science/科学、知识、技能", default: true} - - {id: 411, cat: Other, desc: "School/应试、考级、职称、初中以上教育", default: true} - - {id: 412, cat: Books/EBook, desc: "Book/书籍、杂志、报刊、有声书", default: true} - - {id: 413, cat: Other, desc: "Code/IT技术、建模、编程、信息技术、大数据、人工智能", default: true} - - {id: 414, cat: TV/Anime, desc: "Animate/3D动画、2.5次元", default: true} - - {id: 415, cat: Other, desc: "ACGN/二次元、漫画", default: true} - - {id: 416, cat: Other, desc: "Baby/婴幼、儿童、早教、小学及相关", default: true} - - {id: 417, cat: Other, desc: "Resource/素材、数据、图片、文档、模板", default: true} - - {id: 418, cat: PC/0day, desc: "Software/软件、系统、 程序、APP等", default: true} - - {id: 420, cat: XXX, desc: "步兵(步兵/无码)", default: false} - - {id: 421, cat: XXX, desc: "骑兵(骑兵/有码)", default: false} - - {id: 422, cat: XXX, desc: "三级(三级片、限制级电影)", default: false} - - {id: 424, cat: XXX, desc: "H游(游戏及相关)", default: false} - - {id: 423, cat: XXX, desc: "H漫(动漫、漫画)", default: false} - - {id: 425, cat: XXX, desc: "H书(书籍、有声书)", default: false} - - {id: 426, cat: XXX, desc: "H图(写真、图片、私拍、短视频)", default: false} - - {id: 427, cat: XXX, desc: "H音(ASMR、音频、音乐)", default: false} - - {id: 428, cat: XXX, desc: "H综(综艺、综合、剪辑、其他等)", default: false} - - {id: 429, cat: XXX, desc: "H同(男同、女同、人妖)", default: false} - - {id: 419, cat: Other, desc: "Other(其它,确认上边分类无)", default: true} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419] - - path: special.php - categories: [420, 421, 422, 423, 424, 425, 426, 427, 428, 429] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptfiles.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptfiles.yml deleted file mode 100644 index 3d8a0fd9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptfiles.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: ptfiles -name: PTFiles -description: "PTFiles (PTF) is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: windows-1252 -links: - - https://ptfiles.net/ - -caps: - categorymappings: - - {id: 19, cat: Movies/SD, desc: "Movies/XviD"} - - {id: 20, cat: Movies/DVD, desc: "Movies/DVD-R"} - - {id: 60, cat: Movies/SD, desc: "Movies/SD X264"} - - {id: 23, cat: TV/Anime, desc: "Anime"} - - {id: 1, cat: PC/0day, desc: "Apps"} - - {id: 55, cat: Books, desc: "Comics"} - - {id: 5, cat: Movies/HD, desc: "Movies/720p HD"} - - {id: 8, cat: Movies/HD, desc: "Movies/1080p HD"} - - {id: 15, cat: Movies/UHD, desc: "Movies/Ultra-HD"} - - {id: 44, cat: Movies, desc: "Movies/Packs"} - - {id: 69, cat: Books, desc: "E Books"} - - {id: 12, cat: Books, desc: "E Learning"} - - {id: 11, cat: PC/Mac, desc: "Mac OS"} - - {id: 7, cat: TV/SD, desc: "TV/XviD Episodes"} - - {id: 42, cat: TV/SD, desc: "TV/SD X264 Episodes"} - - {id: 33, cat: TV/HD, desc: "TV/HD Episodes"} - - {id: 22, cat: Other, desc: "Misc"} - - {id: 24, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 6, cat: Audio, desc: "Music"} - - {id: 39, cat: TV, desc: "TV/Seasons"} - - {id: 4, cat: PC/Games, desc: "Games/PC"} - - {id: 29, cat: Console, desc: "Games/Consoles"} - - {id: 9, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must login to the site at least every 119 days. If you get to the 120th day without logging in, your account will be Deleted/Pruned automatically by the system." - -login: - path: loginproc/ - method: form - form: form#login - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - force_ssl: on - ssl: "" - selectorinputs: - hash: - selector: input[name="hash"] - attribute: value - error: - - selector: p.error:contains(strong) - test: - path: index.php - selector: a[href*="&logout_hash="] - -search: - paths: - # https://ptfiles.net/browse.php?search=tt3612126&incldead=1&title=1 - # https://ptfiles.net/browse.php?free=1&search=&incldead=1&title=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 active, 1 both, 2 dead, 3 needseed - incldead: 0 - # 0 title, 1 descr, 2 both, 3 genre - title: "{{ if .Query.IMDBID }}1{{ else }}0{{ end }}" - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - # for some users (don't know why) the table is called tablethree instead of tortable - selector: table#tortable > tbody > tr.rowhead:has(a[href^="dl.php/"]), table#tablethree > tbody > tr.rowhead:has(a[href^="dl.php/"]) - - fields: - # column 3 will be the wait time for new users - download: - selector: a[href^="dl.php/"] - attribute: href - title: - selector: a.torrentlink - attribute: title - category: - selector: td[onclick*="browse.php?cat="] - attribute: onclick - filters: - - name: querystring - args: cat - - name: trim - args: "'" - details: - selector: a.torrentlink - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - files: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(3) > span - filters: - - name: regexp - args: ([\d\.]+) - size: - selector: td:nth-last-child(3) - remove: span - seeders: - selector: td:nth-last-child(2) > span > b:nth-child(1) - leechers: - selector: td:nth-last-child(2) > span > b:nth-child(2) - date: - # timeago - selector: td:nth-child(2) > span > span > small - filters: - - name: replace - args: ["Uploaded: ", ""] - downloadvolumefactor: - case: - ":root:has(div.rounded_colhead2:contains(\"w00t It's Crazyhour!\"))": 0 # Freeleech hour - "span.tool:contains(\"[FREE]\")": 0 - "*": 1 - uploadvolumefactor: - case: - ":root:has(div.rounded_colhead2:contains(\"w00t It's Crazyhour!\"))": 3 # Freeleech hour - "*": 1 - genre: - selector: td:nth-child(2) > span:has(span > small) - remove: span:nth-child(1), span.tool, a[title^="Search all"], br ~ b > small - description: - text: "{{ .Result.genre }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TBDev Custom diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptgtk.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptgtk.yml deleted file mode 100644 index a5ae6d80..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptgtk.yml +++ /dev/null @@ -1,208 +0,0 @@ ---- -id: ptgtk -name: PT GTK -description: "PT GTK is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: -# backup domain - https://pt1.gtkpw.xyz/ - - https://pt.gtkpw.xyz/ -legacylinks: - - https://pt.gtk.pw/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(电影)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(纪录片)"} - - {id: 405, cat: TV/Anime, desc: "Animations(动画)"} - - {id: 402, cat: TV, desc: "TV Series(剧集)"} - - {id: 403, cat: TV, desc: "TV Shows(综艺)"} - - {id: 406, cat: Audio/Video, desc: "Music Videos(MV)"} - - {id: 407, cat: TV/Sport, desc: "Sports(运动题材)"} - - {id: 409, cat: Other, desc: "Misc(其他)"} - - {id: 408, cat: Audio, desc: "HQ Audio(高清音频)"} - - {id: 410, cat: Books, desc: "Book(图书)"} - - {id: 411, cat: Audio, desc: "Music Album(音乐专辑)"} - - {id: 412, cat: Other, desc: "Education(资料)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the PT GTK Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pthome.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pthome.yml deleted file mode 100644 index e0b20315..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pthome.yml +++ /dev/null @@ -1,143 +0,0 @@ ---- -id: pthome -name: PThome -description: "PTHome is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -followredirect: true -links: - - https://www.pthome.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(电影)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(记录片)"} - - {id: 405, cat: TV/Anime, desc: "Animations(动漫)"} - - {id: 402, cat: TV, desc: "TV Series(电视剧)"} - - {id: 403, cat: TV/Other, desc: "TV Shows(综艺)"} - - {id: 406, cat: Audio/Video, desc: "Music Videos(MV)"} - - {id: 407, cat: TV/Sport, desc: "Sports(体育)"} - - {id: 408, cat: Audio/Lossless, desc: "HQ Audio(音乐)"} - - {id: 410, cat: Console, desc: "Games(游戏)"} - - {id: 411, cat: PC, desc: "Software(软件)"} - - {id: 412, cat: Books, desc: "Study(学习)"} - - {id: 409, cat: Other, desc: "Misc(其他)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using cookie method because I could not get the form method to work when I first created the indexer, some hidden security check - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - incldead: 0 - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - search_mode: 0 - notnewword: 1 - - rows: - selector: table.torrents tr:has(a[href^="?cat="]) - filters: - - name: andmatch - - fields: - title: - selector: td.torrents-box a - attribute: title - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php", "download.php"] - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v3.1 2021-07-05 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptitzmx.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptitzmx.yml deleted file mode 100644 index b5915898..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptitzmx.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: ptitzmx -name: PT分享站 (itzmx) -description: "PT分享站 (itzmx) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.itzmx.com/ - -caps: - categorymappings: - - {id: 405, cat: TV/Anime, desc: "Anime.动画"} - - {id: 404, cat: Books/Comics, desc: "Comics.漫画"} - - {id: 408, cat: Audio, desc: "Music.音乐"} - - {id: 401, cat: Movies, desc: "Movies.电影"} - - {id: 402, cat: TV, desc: "TV.电视剧"} - - {id: 414, cat: Movies/BluRay, desc: "MoviesBluray.蓝光"} - - {id: 410, cat: PC, desc: "Appz.软件"} - - {id: 411, cat: Console, desc: "Games.游戏"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User and above users will be retained forever
  2. Elite User and above users will not be deleted after parking their accounts (in the control panel)
  3. Users with parked accounts will have their accounts deleted if they do not log in for 300 consecutive days
  4. Users with un-parked accounts will have their accounts deleted if they do not log in for 90 consecutive days
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 7 days in a row.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }} {{ else }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }} {{ else }}{{ end }}{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (does not work) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description: - selector: td:nth-child(2) - remove: a, img -# NexusPHP v1.5 2014-04-14 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptlao.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptlao.yml deleted file mode 100644 index 434fcfdb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptlao.yml +++ /dev/null @@ -1,195 +0,0 @@ ---- -id: ptlao -name: PTLAO -description: "PTLAO is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ptlao.top/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: TV, desc: "TV Series"} - - {id: 403, cat: TV, desc: "TV Shows"} - - {id: 405, cat: TV/Anime, desc: "Animations"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries"} - - {id: 408, cat: Audio, desc: "HQ Audio"} - - {id: 409, cat: Other, desc: "Misc"} - - {id: 407, cat: TV/Sport, desc: "Sports"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the PTLAO Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite Users and above will not be deleted after parking (in the control panel)
  3. Users with a parked account will be banned if they do not log in for 300 consecutive days
  4. Users who do not log in for 150 consecutive days will be banned
  5. Newly registered users with no traffic within 30 days, will be banned.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.6 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.13 2024-09-21 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptlgs.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptlgs.yml deleted file mode 100644 index 23f9a18a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptlgs.yml +++ /dev/null @@ -1,203 +0,0 @@ ---- -id: ptlgs -name: PTLGS -description: "PTLGS is a CHINESE Private Torrent Tracker for MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ptlgs.org/ - -caps: - categorymappings: - - {id: 409, cat: Other/Misc, desc: "其他(Other)"} - - {id: 407, cat: TV/Sport, desc: "体育(Sports)"} - - {id: 406, cat: Audio/Video, desc: "音乐(Music Video)"} - - {id: 403, cat: TV, desc: "综艺(Variety Shows)"} - - {id: 402, cat: TV, desc: "剧集(Drama Serie)"} - - {id: 405, cat: TV/Anime, desc: "动漫/Anime)"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentary)"} - - {id: 401, cat: Movies, desc: "电影(Movies)"} - - {id: 411, cat: Other, desc: "字幕组(Subtitle Group)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the PTLGS Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. All users' accounts will be retained forever, and data will be retained forever
  2. Newly registered accounts without traffic (i.e., upload/download data are 0) will be automatically frozen by the system if they do not log in for 7 consecutive days
  3. Accounts that have been inactive for more than 30 days will be automatically banned by the system
  4. determination method: When there is an unread announcement, it is necessary to manually confirm that the announcement has been read to be considered active; When there are no unread announcements, you need to visit the homepage to be active.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 5 hours (as seconds = 5 x 60 x 60) - text: 18000 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.13 2024-09-21 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptsbao.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptsbao.yml deleted file mode 100644 index 48d360b9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptsbao.yml +++ /dev/null @@ -1,169 +0,0 @@ ---- -id: ptsbao -name: PTSBAO (烧包) -description: "PTSBAO (烧包) is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -followredirect: true -links: - - https://ptsbao.club/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(电影)"} - - {id: 402, cat: TV, desc: "TV Series(电视剧)"} - - {id: 403, cat: TV/Other, desc: "TV Shows(电视节目)"} - - {id: 405, cat: TV/Anime, desc: "Animations(动漫)"} - - {id: 414, cat: Audio, desc: "Music(音乐)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(记录片)"} - - {id: 423, cat: Movies, desc: "Movies Original(原创)"} - - {id: 409, cat: Other, desc: "Misc(其他)"} - - {id: 424, cat: Other, desc: "Limit(限制)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # switched from the form method to the cookie method due to #9554 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # does not return imdb link in results - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: table.torrentname td a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - filters: - - name: replace - args: ["pic/imdb_pic/nophoto.gif", ""] - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 -# NexusPHP v1.10.0 2026-02-01 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptskit.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptskit.yml deleted file mode 100644 index e8b71fbc..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptskit.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -id: ptskit -name: PTSKIT -description: "PTSKIT is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.ptskit.org/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: TV/Other, desc: "短剧(Playlet)", default: true} - - {id: 402, cat: TV, desc: "动态漫(Animation)", default: true} - - {id: 404, cat: Movies, desc: "电影(Movies)", default: true} - - {id: 405, cat: TV, desc: "电视剧(TV Series)", default: true} - - {id: 409, cat: TV, desc: "综艺(TV Shows)", default: true} - - {id: 408, cat: Audio/Video, desc: "音乐(Music Video)", default: true} - - {id: 403, cat: TV, desc: "游戏(TV)", default: true} - - {id: 407, cat: TV/Anime, desc: "动漫/漫画(Anime/Manga)", default: true} - - {id: 406, cat: Other, desc: "其他(Other)", default: true} - - {id: 411, cat: XXX, desc: "日韩(Japanese/Korean)", default: false} - - {id: 410, cat: XXX, desc: "国产(Chinese)", default: false} - - {id: 412, cat: XXX, desc: "欧美(European/American)", default: false} - - {id: 413, cat: XXX, desc: "里番(HiAnime)", default: false} - - {id: 414, cat: XXX, desc: "漫画(Hentai)", default: false} - - {id: 415, cat: XXX/Other, desc: "游戏(Games)", default: false} - - {id: 416, cat: XXX/Other, desc: "其他(Other)", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 404, 405, 409, 408, 403, 407, 406] - - path: special.php - categories: [411, 410, 412, 413, 414, 415, 416] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.14 2026-01-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pttey.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pttey.yml deleted file mode 100644 index 21ce2b16..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pttey.yml +++ /dev/null @@ -1,168 +0,0 @@ ---- -id: pttey -name: PTTey -description: "PTTey is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.tey.cc/ -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(电影)"} - - {id: 402, cat: TV, desc: "TV Series(电视剧)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(记录片)"} - - {id: 403, cat: TV/Other, desc: "TV Shows(电视节目)"} - - {id: 405, cat: TV/Anime, desc: "Animations(动漫)"} - - {id: 407, cat: TV/Sport, desc: "Sports(体育)"} - - {id: 406, cat: Audio/Video, desc: "Music Videos(MV)"} - - {id: 408, cat: Audio, desc: "Music(音乐)"} - - {id: 409, cat: Other, desc: "Misc(其他)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # does not return imdb link in results - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: table.torrentname td a[href^="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 -# NexusPHP v1.10.0 2026-02-01 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pttime.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pttime.yml deleted file mode 100644 index 06420fed..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pttime.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -id: pttime -name: PTTime -description: "PTTime is a ratioless CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 5 -links: - - https://www.pttime.org/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(电影)", default: true} - - {id: 402, cat: TV, desc: "TV Series(电视剧)", default: true} - - {id: 403, cat: TV, desc: "TV Shows(综艺)", default: true} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(纪录片)", default: true} - - {id: 405, cat: TV/Sport, desc: "Sport(体育、竞技、武术及相关)", default: true} - - {id: 406, cat: PC/Games, desc: "Games(游戏及相关)", default: true} - - {id: 408, cat: Audio, desc: "Music(音乐、专辑、MV、演唱会)", default: true} - - {id: 409, cat: Other, desc: "Art(舞蹈、歌剧、戏曲、相声、评书等)", default: true} - - {id: 411, cat: Other, desc: "Science(科学、知识、技能)", default: true} - - {id: 412, cat: Other, desc: "School(应试、考级、初中以上教育)", default: true} - - {id: 420, cat: PC, desc: "Code(IT、编程、大数据、人工智能", default: true} - - {id: 430, cat: TV/Anime, desc: "Animate(3D动画、2.5次元)", default: true} - - {id: 432, cat: TV/Other, desc: "Baby(婴幼、早教、小学及相关)", default: true} - - {id: 450, cat: Other, desc: "Resource(资源、素材、模板)", default: true} - - {id: 451, cat: PC, desc: "Software(软件、系统、 程序、APP等)", default: true} - - {id: 490, cat: Other, desc: "Other(其它)", default: true} - - {id: 440, cat: XXX, desc: "9kg-AV(步兵/无码)", default: false} - - {id: 441, cat: XXX, desc: "9kg-Sex(骑兵/有码)", default: false} - - {id: 442, cat: XXX, desc: "9kg-III(三级片、限制级电影)", default: false} - - {id: 443, cat: XXX, desc: "9kg-H动漫", default: false} - - {id: 444, cat: XXX, desc: "9kg-H游戏", default: false} - - {id: 445, cat: XXX, desc: "9kg-H书籍(书籍、有声书)", default: false} - - {id: 446, cat: XXX, desc: "9kg-H写真(图片、私拍、短视频)", default: false} - - {id: 447, cat: XXX, desc: "9kg-H音频(ASMR、音乐、音频)", default: false} - - {id: 448, cat: XXX, desc: "9kg-H综(综艺、综合、剪辑、其他等)", default: false} - - {id: 449, cat: XXX, desc: "9kg-H同(男同、女同、人妖)", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel).
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - path: takelogin.php - method: post - inputs: - loginmethod: username - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - dutime: month - ssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 403, 404, 405, 406, 408, 409, 411, 412, 420, 430, 432, 450, 451, 490] - - path: adults.php - categories: [440, 441, 442, 443, 444, 445, 446, 447, 448, 449] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30%, 8 neutral - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 1 title&subtitle, 2 titleonly, 3 descr - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}3{{ else }}2{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: td.torrentimg > img - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(5) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(5):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - case: - font.promotion.free: 0 - font.promotion.twoupfree: 0 - font.promotion.halfdown: 0.5 - font.promotion.twouphalfdown: 0.5 - font.promotion.thirtypercent: 0.3 - "*": 1 - uploadvolumefactor: - case: - font.promotion.twoup: 2 - font.promotion.twoupfree: 2 - font.promotion.twouphalfdown: 2 - "*": 1 - description: - selector: td:nth-child(2) > font:last-child -# NexusPHP Standard v1.5 Beta 4 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptying.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptying.yml deleted file mode 100644 index bc392360..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptying.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: ptying -name: PTYING (樱花) -description: "PTYING (樱花) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.ying.us.kg/ - -caps: - categorymappings: - - {id: 409, cat: Other, desc: "HQ Misc/其他"} - - {id: 408, cat: Audio, desc: "HQ Audio/音频"} - - {id: 406, cat: Audio/Video, desc: "Music Video/MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 410, cat: TV, desc: "Playlet/短剧"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Anime/动漫"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the PTYING Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptzone.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptzone.yml deleted file mode 100644 index 6ad48e65..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ptzone.yml +++ /dev/null @@ -1,189 +0,0 @@ ---- -id: ptzone -name: PTzone -description: "PTzone is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ptzone.xyz/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(电影)"} - - {id: 402, cat: TV, desc: "TV Series(电视剧)"} - - {id: 403, cat: TV, desc: "TV Shows(综艺)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(纪录片)"} - - {id: 405, cat: TV/Anime, desc: "Animations(动漫)"} - - {id: 406, cat: Audio, desc: "Music(音乐)"} - - {id: 407, cat: TV/Sport, desc: "Sports(体育)"} - - {id: 408, cat: Other, desc: "Others(其它)"} - - {id: 410, cat: PC/0day, desc: "Software(软件)"} - - {id: 411, cat: PC/Games, desc: "Games(游戏)"} - - {id: 409, cat: TV, desc: "Others(其它)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (not working) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.9 2025-10-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/punkshorror.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/punkshorror.yml deleted file mode 100644 index 46baf755..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/punkshorror.yml +++ /dev/null @@ -1,174 +0,0 @@ ---- -id: punkshorror -name: Punk's Horror Tracker -description: "Punk's Horror Tracker is a HUNGARIAN Private Tracker for Horror MOVIES / TV" -language: hu-HU -type: private -encoding: ISO-8859-2 -links: - - https://punck-tracker.net/ - -caps: - categorymappings: - - {id: 87, cat: Movies, desc: "Action Eng"} - - {id: 88, cat: Movies, desc: "Action Hun"} - - {id: 79, cat: TV/Documentary, desc: "Dokumentumfilm"} - - {id: 72, cat: Movies/HD, desc: "HD Eng"} - - {id: 71, cat: Movies/HD, desc: "HD Hun"} - - {id: 89, cat: Movies, desc: "Horror Anime"} - - {id: 78, cat: Books, desc: "Horror Ebook"} - - {id: 66, cat: Movies, desc: "Horror Eng"} - - {id: 73, cat: PC/Games, desc: "Horror Game"} - - {id: 65, cat: Movies, desc: "Horror Hun"} - - {id: 82, cat: XXX, desc: "Horror XXX"} - - {id: 94, cat: Movies, desc: "Kamerás filmek"} - - {id: 98, cat: Movies/HD, desc: "MD-HD"} - - {id: 86, cat: Movies, desc: "Mystery-Fantasy Eng"} - - {id: 85, cat: Movies, desc: "Mystery-Fantasy Hun"} - - {id: 83, cat: Movies, desc: "Punk Release"} - - {id: 76, cat: Movies, desc: "Retro Film"} - - {id: 70, cat: Movies, desc: "Scfi Eng"} - - {id: 69, cat: Movies, desc: "Scfi Hun"} - - {id: 75, cat: TV, desc: "Sorozat Eng"} - - {id: 74, cat: TV, desc: "Sorozat Hun"} - - {id: 68, cat: Movies, desc: "Thriller Eng"} - - {id: 67, cat: Movies, desc: "Thriller Hun"} - - {id: 84, cat: Movies/SD, desc: "VHS Ritkaságok"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search FreeLeech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: added - 5: size - 7: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrentek száma egy oldalon setting to 100 on your account profile. - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: "td:contains(\"Belépési hiba\")" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - onlyname: 1 - search: "{{ .Keywords }}" - # 0 active, 1 all, 2 deadonly, 3 freeleech - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not support imdbid searches - - rows: - selector: table.alap99 > tbody#linkhatter > tr - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - # can be abbreviated - selector: a[href^="details.php?id="] - title: - # usually full length - selector: a[href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(2) > b > font - filters: - - name: replace - args: ["\xA0", ""] - - name: replace - args: ["Feltöltve:", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: src=([^\s]+) - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - description: - case: - img[src="pic/yes.png"]: Verified - img[src="pic/nincs.png"]: Unverified - downloadvolumefactor: - case: - img[src="pic/golden.gif"]: 0 - "*": 1 - uploadvolumefactor: - selector: td:nth-child(7) > center > b > font - filters: - - name: regexp - args: (\d+) - minimumratio: - text: 0.8 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/puntotorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/puntotorrent.yml deleted file mode 100644 index c1b9f410..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/puntotorrent.yml +++ /dev/null @@ -1,359 +0,0 @@ ---- -id: puntotorrent -replaces: - - puntorrent -name: PuntoTorrent -description: "PuntoTorrent is a SPANISH Private Torrent Tracker for 0DAY / GENERAL" -language: es-ES -type: private -encoding: UTF-8 -links: - - https://xbt.puntotorrent.com/ -legacylinks: - - https://xbt.puntotorrent.ch/ - -caps: - categorymappings: - - {id: 37, cat: TV/Anime, desc: "DVD - Animación"} - - {id: 38, cat: TV/Sport, desc: "DVD - Deportes"} - - {id: 39, cat: TV/Documentary, desc: "DVD - Documentales"} - - {id: 40, cat: Movies/DVD, desc: "DVD - Películas"} - - {id: 41, cat: TV/SD, desc: "DVD - Series"} - - {id: 42, cat: XXX/DVD, desc: "DVD - Adulto (+18)"} - - {id: 43, cat: Audio/Video, desc: "DVD - Música"} - - {id: 45, cat: TV/Anime, desc: "XVID - Animación"} - - {id: 46, cat: TV/Sport, desc: "XVID - Deportes"} - - {id: 47, cat: TV/Documentary, desc: "XVID - Documentales"} - - {id: 48, cat: Movies/SD, desc: "XVID - Películas"} - - {id: 33, cat: Movies/SD, desc: "XVID - Estrenos"} - - {id: 114, cat: Movies/SD, desc: "XVID - Estrenos BajaCalidad"} - - {id: 14, cat: TV/SD, desc: "XVID - Series"} - - {id: 12, cat: XXX/XviD, desc: "XVID - Adulto (+18)"} - - {id: 51, cat: Audio/Video, desc: "XVID - Música"} - - {id: 106, cat: TV/Anime, desc: "HDRip - Animación"} - - {id: 107, cat: TV/Sport, desc: "HDRip - Deportes"} - - {id: 108, cat: TV/Documentary, desc: "HDRip - Documentales"} - - {id: 109, cat: Movies/HD, desc: "HDRip - Películas"} - - {id: 110, cat: TV/HD, desc: "HDRip - Series"} - - {id: 112, cat: XXX/x264, desc: "HDRip - Adulto (+18)"} - - {id: 111, cat: Audio/Video, desc: "HDRip - Música"} - - {id: 53, cat: TV/Anime, desc: "HD - Animación"} - - {id: 54, cat: TV/Sport, desc: "HD - Deportes"} - - {id: 55, cat: TV/Documentary, desc: "HD - Documentales"} - - {id: 56, cat: Movies/HD, desc: "HD - BDrip/BDremux/FullBD"} - - {id: 59, cat: TV/HD, desc: "HD - Series"} - - {id: 63, cat: XXX/x264, desc: "HD - Adulto (+18)"} - - {id: 61, cat: Audio/Video, desc: "HD - Música"} - - {id: 144, cat: TV/Anime, desc: "WEB-DL - Animación"} - - {id: 145, cat: TV/Sport, desc: "WEB-DL - Deportes"} - - {id: 146, cat: TV/Documentary, desc: "WEB-DL - Documentales"} - - {id: 147, cat: Movies/HD, desc: "WEB-DL - Películas"} - - {id: 148, cat: TV/HD, desc: "WEB-DL - Series"} - - {id: 149, cat: XXX/x264, desc: "WEB-DL - Adulto (+18)"} - - {id: 150, cat: Audio/Video, desc: "WEB-DL - Música"} - - {id: 136, cat: TV/Anime, desc: "UHD - Animación"} - - {id: 137, cat: TV/Sport, desc: "UHD - Deportes"} - - {id: 138, cat: TV/Documentary, desc: "UHD - Documentales"} - - {id: 139, cat: Movies/UHD, desc: "UHD - Películas"} - - {id: 140, cat: TV/UHD, desc: "UHD - Series"} - - {id: 141, cat: XXX/x264, desc: "UHD - Adulto (+18)"} - - {id: 126, cat: TV/Anime, desc: "Micro HD - Animación"} - - {id: 127, cat: TV/Sport, desc: "Micro HD - Deportes"} - - {id: 128, cat: TV/Documentary, desc: "Micro HD - Documentales"} - - {id: 57, cat: Movies/HD, desc: "Micro HD - Películas"} - - {id: 124, cat: TV/HD, desc: "Micro HD - Series"} - - {id: 129, cat: XXX/x264, desc: "Micro HD - Adulto"} - - {id: 130, cat: Audio/Video, desc: "Micro HD - Música"} - - {id: 117, cat: TV/Anime, desc: "3D - Animación"} - - {id: 118, cat: TV/Sport, desc: "3D - Deportes"} - - {id: 119, cat: TV/Documentary, desc: "3D - Documentales"} - - {id: 120, cat: Movies/3D, desc: "3D - Películas"} - - {id: 121, cat: XXX, desc: "3D - Adulto (+18)"} - - {id: 122, cat: Audio/Video, desc: "3D - Música"} - - {id: 92, cat: Other, desc: "Otros formatos - Animación"} - - {id: 93, cat: Other, desc: "Otros formatos - Deportes"} - - {id: 94, cat: Other, desc: "Otros formatos - Documentales"} - - {id: 95, cat: Other, desc: "Otros formatos - Películas"} - - {id: 100, cat: Other, desc: "Otros formatos - Estrenos"} - - {id: 97, cat: Other, desc: "Otros formatos - Series"} - - {id: 98, cat: Other, desc: "Otros formatos - Adulto (+18)"} - - {id: 99, cat: Other, desc: "Otros formatos - Música"} - - {id: 69, cat: Audio/MP3, desc: "Música - MP3"} - - {id: 70, cat: Audio/Lossless, desc: "Música - FLAC"} - - {id: 71, cat: Audio/Other, desc: "Música - Otros Formatos"} - - {id: 6, cat: Books/EBook, desc: "eBooks - eBooks"} - - {id: 142, cat: Audio/Audiobook, desc: "eBooks - Audiolibros"} - - {id: 131, cat: Books/Comics, desc: "eBooks - Cómics"} - - {id: 134, cat: Books, desc: "eBooks - Manga"} - - {id: 132, cat: Books/Mags, desc: "eBooks - Revistas"} - - {id: 133, cat: Books, desc: "eBooks - Adulto (+18)"} - - {id: 72, cat: Books/Other, desc: "eBooks - Otros"} - - {id: 25, cat: PC/Games, desc: "Juegos - Microsoft"} - - {id: 26, cat: Console, desc: "Juegos - Sony"} - - {id: 27, cat: PC/Games, desc: "Juegos - PC (Windows)"} - - {id: 28, cat: Console/NDS, desc: "Juegos - Nintendo"} - - {id: 113, cat: Console/Other, desc: "Juegos - Emuladores/Otras P."} - - {id: 74, cat: PC/0day, desc: "Software - Windows"} - - {id: 75, cat: PC, desc: "Software - Linux"} - - {id: 76, cat: PC/Mac, desc: "Software - MAC"} - - {id: 115, cat: PC/Mobile-Android, desc: "Software - Android"} - - {id: 77, cat: PC/Mobile-Other, desc: "Software - Otras Plataformas"} - # hidden but with torrents - - {id: 17, cat: Movies/Other, desc: "VCD/SVCD - SVCD/VCD"} - - {id: 78, cat: Movies/Other, desc: "VCD/SVCD - Estrenos VCD/SVCD"} - - {id: 102, cat: Movies/Other, desc: "XVID - Películas"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: .lista>span - test: - path: index.php - selector: form[name="jump1"] - -search: - paths: - - path: index.php - keywordsfilters: - - name: re_replace - args: ["(?i)S(\\d+)", " $1 "] - - name: re_replace - args: ["(?i)E(\\d+)", " $1 "] - - name: re_replace - args: ["[\\.\\-,_\\[\\]\\(\\)]+", " "] - inputs: - page: torrents - $raw: "&category={{ range .Categories }}{{.}};{{end}}" - # 0 all, 1 onlyactive, 2 onlydead, 3 gold, 4 silver, 5 upx2, 6 upx3 - active: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - search: "{{ .Keywords }}" - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: "#mcol .block-content-l table table.lista > tbody > tr:not(:first-child):not(.descripcion)" - - fields: - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: querystring - args: category - - _remux: # Remux - selector: td:nth-child(2) a:contains("REMUX"),a:contains("Remux"),a:contains("remux") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "Remux." - - _webdl: # WEBDL - selector: td:nth-child(2) a:contains("WEB"),a:contains("web"),a:contains("Web") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "WEBDL." - - _hdtv: # HDTV - selector: td:nth-child(2) a:contains("HDTV"),a:contains("HDtv"),a:contains("hdtv") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "HDTV." - - _dvd: # DVD - selector: td:nth-child(2) a:contains("DVD"),a:contains("dvd"),a:contains("Dvd") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "DVD.Spanish-PuntoTorrent" - - _bluray: # BluRay - selector: td:nth-child(2) a:not(:contains("HDTV"))a:not(:contains("hdtv"))a:not(:contains("REMUX"))a:not(:contains("Remux"))a:not(:contains("remux"))a:not(:contains("WEB"))a:not(:contains("web"))a:not(:contains("Web"))a:contains("1080"),:contains("2160"):contains("uhd") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "BluRay." - - _2160p: # 2160p - selector: td:nth-child(2) a:contains("2160"),a:contains("4k"),a:contains("4K"),a:contains("uhd"),a:contains("UHD") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "2160p.Spanish-PuntoTorrent" - - _1080p: # 1080p - selector: td:nth-child(2) a:contains("1080"),a:contains("microhd"),a:contains("mhd"),a:contains("BDRemux"):not(:contains("2160")) - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "1080p.Spanish-PuntoTorrent" - - _720p: # 720p - selector: td:nth-child(2) a:contains("720") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "720p.Spanish-PuntoTorrent" - - _480p: # 480p - selector: td:nth-child(2) a:contains("480"),a:contains("Xvid"),a:contains("XVID"),a:contains("xvid") - optional: true - filters: - - name: re_replace - args: [".", ""] - - name: append - args: "480p.Spanish-PuntoTorrent" - - title_default: # Clean Title - selector: td:nth-child(2) - optional: true - filters: - - name: re_replace - args: ["(?i)T[\\s-_]?(\\d{1,2})(.*)\\[(\\d{1,2})[x|\\/|\\\\](\\d{1,2})\\]", "S$1E$3 $2"] - - name: re_replace - args: ["(?i)T[\\s-_]?(\\d{1,2})\\b", " S$1 "] - - name: re_replace - args: ["(?i)\\w*Temp\\w*\\b\\s?(\\d{1,2})(ª|\\D)?\\b", " S$1 "] - - name: re_replace - args: ["(?i)(\\d{1,2})(ª|\\D)?\\s?\\w*Temp\\w*\\b", " S$1 "] - - name: re_replace - args: ["(?i)(\\d{1,2})(ª|\\D)?\\s?\\w*Temp\\w*\\b", " S$1 "] - - name: re_replace - args: ["(?i)(\\d{1,2})[x|\\/|\\\\](\\d{1,2})", "S$1E$2"] - - name: re_replace # S02/C01 => S02E01 / S02xC01 => S02E01 / S02C01 => S02E01 - args: ["(?i)S(\\d{1,2})[/x]?C(\\d{1,2})", "S$1E$2"] - - name: re_replace - args: ["(?i)\\[\\.[a-zA-Z.]+\\]", ""] # Delete [.Group] - - name: re_replace - args: [" SILVER:.*", ""] # Delete [SILVER...] - - name: re_replace - args: [" Torrent.*", ""] # Delete [Torrent x2...] - - name: re_replace - args: [" GOLDEN:.*", ""] # Delete [GOLDEN...] - - name: re_replace - args: ["\\W", "."] # Spaces and other characters -> . - - name: re_replace - args: ["\\.+", "."] # More than 1 dot -> . - - name: re_replace - args: ["^\\.", ""] # Delete first dot - - name: append - args: " {{ .Result._remux }}{{ .Result._webdl }}{{ .Result._hdtv }}{{ .Result._dvd }}{{ .Result._bluray }}{{ .Result._2160p }}{{ .Result._1080p }}{{ .Result._720p }}{{ .Result._480p }}" - - title_vose: - selector: td:nth-child(2) a:contains("VOSE") - optional: true - filters: - - name: append - args: " | English" - - name: re_replace - args: ["(?i)T[\\s-_]?(\\d{1,2})(.*)\\[(\\d{1,2})[x|\\/|\\\\](\\d{1,2})\\]", "S$1E$3 $2"] - - name: re_replace - args: ["(?i)T[\\s-_]?(\\d{1,2})\\b", " S$1 "] - - name: re_replace - args: ["(?i)\\w*Temp\\w*\\b\\s?(\\d{1,2})(ª|\\D)?\\b", " S$1 "] - - name: re_replace - args: ["(?i)(\\d{1,2})(ª|\\D)?\\s?\\w*Temp\\w*\\b", " S$1 "] - - name: re_replace - args: ["(?i)(\\d{1,2})[x|\\/|\\\\](\\d{1,2})", "S$1E$2"] - - name: append - args: " {{ .Result._remux }}{{ .Result._webdl }}{{ .Result._hdtv }}{{ .Result._dvd }}{{ .Result._bluray }}{{ .Result._2160p }}{{ .Result._1080p }}{{ .Result._720p }}{{ .Result._480p }}" - - title: - text: "{{ if .Result.title_vose }}{{ .Result.title_vose }}{{ else }}{{ .Result.title_default }}{{ end }}" - - details: - selector: a[href^="download.php"] - attribute: href - filters: - - name: replace - args: ["download.php?", "index.php?page=torrent-details&"] - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(8) a - leechers: - selector: td:nth-child(9) a - grabs: - selector: td:nth-child(10) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd/MM/yyyy zzz" - download: - selector: a[href^="download.php"] - attribute: href - downloadvolumefactor: - case: - img[src$="golden.gif"]: 0 - img[src$="silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="x2.gif"]: 2 - img[src$="x3.gif"]: 3 - "*": 1 -# xbtit 2.3.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pussytorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pussytorrents.yml deleted file mode 100644 index 1ea1d977..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pussytorrents.yml +++ /dev/null @@ -1,115 +0,0 @@ ---- -id: pussytorrents -name: PussyTorrents -description: "PussyTorrents is a Semi-Private Torrent Tracker for 3X" -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://pussytorrents.org/ - -caps: - categorymappings: - - {id: 1, cat: XXX, desc: "XXX"} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - nameSort: title - score: score - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_score - type: info - label: About sorting by score - default: "In order to sort by score you first must have edited your PussyTorrents account profile and changed Results default sorting method to By Score." - -login: - path: user/account/login/ - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - remember_me: on - error: - - selector: div.alert-error - test: - path: / - selector: a[href="/user/account/logout"] - -search: - # https://pussytorrents.org/torrents/browse?query=&page=1&order=desc&orderby=added - paths: - - path: torrents/browse - inputs: - query: "{{ .Keywords }}" - page: 1 - orderby: "{{ if ne .Config.sort \"score\" }}{{ .Config.sort }}{{ else }}{{ end }}" - order: "{{ if ne .Config.sort \"score\" }}{{ .Config.type }}{{ else }}{{ end }}" - - rows: - selector: table#torrenttable > tbody > tr:has(a[href^="/download/"]) - filters: - - name: andmatch - - fields: - category: - text: 1 - title: - selector: a[href^="/torrent/"] - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="/download/"] - attribute: href - date: - selector: span.subnote - filters: - - name: replace - args: ["Added on ", ""] - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - description: - selector: td.name - remove: span.title, span.subnote - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.4 - minimumseedtime: - # 60 hours (as seconds = 2.5 x 24 x 60 x 60) - text: 216000 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/putao.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/putao.yml deleted file mode 100644 index f7b58c2a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/putao.yml +++ /dev/null @@ -1,201 +0,0 @@ ---- -id: putao -name: PuTao (葡萄) -description: "PuTao (葡萄) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.sjtu.edu.cn/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies CHN 华语电影"} - - {id: 402, cat: Movies, desc: "Movies Eng 欧美电影"} - - {id: 403, cat: Movies, desc: "Movies Asian 亚洲电影"} - - {id: 406, cat: TV/Documentary, desc: "Documentary 纪录片"} - - {id: 407, cat: TV, desc: "TV Series HKTW 港台电视剧"} - - {id: 408, cat: TV, desc: "TV Series Asian 亚洲电视剧"} - - {id: 409, cat: TV, desc: "TV Series CHN 大陆电视剧"} - - {id: 410, cat: TV, desc: "TV Series Eng 欧美电视剧"} - - {id: 411, cat: TV, desc: "TV CHN 大陆综艺节目"} - - {id: 412, cat: TV, desc: "TV HKTW 港台综艺节目"} - - {id: 413, cat: TV, desc: "TV Eng 欧美综艺节目"} - - {id: 414, cat: TV, desc: "TV JPKR 日韩综艺节目"} - - {id: 420, cat: Audio, desc: "Music CHN 华语音乐"} - - {id: 421, cat: Audio, desc: "Music JPKR 日韩音乐"} - - {id: 422, cat: Audio, desc: "Music Eng 欧美音乐"} - - {id: 423, cat: Audio, desc: "Music OST 原声音乐"} - - {id: 425, cat: Audio, desc: "Music Classic 古典音乐"} - - {id: 426, cat: Audio/MP3, desc: "Music mp3合辑"} - - {id: 427, cat: Audio/Video, desc: "Music Videos"} - - {id: 429, cat: Console, desc: "Games 游戏"} - - {id: 431, cat: TV/Anime, desc: "Anime 动漫"} - - {id: 432, cat: TV/Sport, desc: "Sport 体育"} - - {id: 434, cat: PC, desc: "Software 软件"} - - {id: 435, cat: Other, desc: "Study 学习"} - - {id: 440, cat: PC/Mac, desc: "Software Mac"} - - {id: 451, cat: Other, desc: "Campus 校园原创"} - - {id: 450, cat: Other/Misc, desc: "Misc 其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the 每页种子数:每页显示(Torrents per page:) setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 240 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 120 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 30 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - checkcode: XxXx - logout: "" - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href^="logout.php?hash="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 onlyactive, 2 onlydead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 30%, 7 70% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 all, 1 popular, 2 classic, 3 recommended, 4 normal, 5 seeds - picktype: 0 - # 0 title, 1 descr, 3 uploader, 4 imdbid - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # does not return imdb or doubanid in results - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date_elapsed: - # time elapsed (default) - # 19时
3分 (19 hours 3 minutes) - # 2天
21时 (2 days 21 hours) - selector: td:nth-child(4):not(:contains("-")) - optional: true - filters: - - name: replace - args: ["年", "years"] - - name: replace - args: ["月", "months"] - - name: replace - args: ["天", "days"] - - name: replace - args: ["时", " hours"] - - name: replace - args: ["分", "minutes"] - - name: append - args: " ago" - - name: timeago - date_added: - # time added - selector: td:nth-child(4):contains("-") - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_2up: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_30pctdown: 0.3 - img.pro_70pctdown: 0.7 - "*": 1 - uploadvolumefactor: - case: - img.pro_2up: 2 - img.pro_free2up: 2 - "*": 1 - minimumratio: - text: 0.7 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.0 Beta 2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pwtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pwtorrents.yml deleted file mode 100644 index a75a01b6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/pwtorrents.yml +++ /dev/null @@ -1,153 +0,0 @@ ---- -id: pwtorrents -name: PWTorrents -description: "PWTorrents (PWT) is a Private Torrent Tracker for PROFESSIONAL WRESTLING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://pwtorrents.net/ -legacylinks: - - http://pwtorrents.net/ - -caps: - categorymappings: - - {id: 26, cat: TV/Sport, desc: "AEW Dark"} - - {id: 21, cat: TV/Sport, desc: "AEW Dynamite"} - - {id: 20, cat: TV/Sport, desc: "AEW PPV"} - - {id: 23, cat: TV/Sport, desc: "AEW Rampage"} - - {id: 28, cat: TV/Sport, desc: "AEW Collision"} - - {id: 1, cat: TV/Sport, desc: "Boxing"} - - {id: 2, cat: TV/Sport, desc: "Documentaries"} - - {id: 3, cat: TV/Sport, desc: "DVD"} - - {id: 4, cat: TV/Sport, desc: "ECW"} - - {id: 13, cat: TV/Sport, desc: "Impact PPV"} - - {id: 14, cat: TV/Sport, desc: "Impact Weekly"} - - {id: 6, cat: TV/Sport, desc: "Indy's"} - - {id: 7, cat: TV/Sport, desc: "Matches"} - - {id: 8, cat: TV/Sport, desc: "Misc."} - - {id: 9, cat: TV/Sport, desc: "MMA"} - - {id: 11, cat: TV/Sport, desc: "Puro"} - - {id: 10, cat: TV/Sport, desc: "PWT Packs"} - - {id: 12, cat: TV/Sport, desc: "Ring Of Honor"} - - {id: 15, cat: TV/Sport, desc: "WCW"} - - {id: 18, cat: TV/Sport, desc: "WWE Network"} - - {id: 16, cat: TV/Sport, desc: "WWE PPV"} - - {id: 24, cat: TV/Sport, desc: "WWE RAW"} - - {id: 25, cat: TV/Sport, desc: "WWE Smackdown"} - - {id: 17, cat: TV/Sport, desc: "WWE Weekly"} - - {id: 27, cat: TV/Sport, desc: "WWE NXT"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: seedbox - type: checkbox - label: "Use SeedBox Download Link" - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table.main:contains("Login failed!") - test: - path: my.php - selector: a[href="logout.php"] - -search: - paths: - # https://pwtorrents.net/browse.php?c18=1&c16=1&c17=1&incldead=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[border="1"][cellspacing="0"][cellpadding="5"] tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - optional: true - default: No Title - details: - selector: a[href^="details.php?id="] - attribute: href - download_1: - selector: a[href^="download.php?id="] - attribute: href - download_2: - selector: a[href^="seeddownload.php?id="] - attribute: href - download: - text: "{{ if .Config.seedbox }}{{ .Result.download_2 }}{{ else }}{{ .Result.download_1 }}{{ end }}" - date: - selector: td:nth-child(4) - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - img[src="pic/freeleech.png"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="gold.png"]: 2 - img[src="silver.png"]: 1.5 - img[src="bronze.png"]: 1.25 - "*": 1 - minimumratio: - text: 0.7 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/qingwa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/qingwa.yml deleted file mode 100644 index 498795ea..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/qingwa.yml +++ /dev/null @@ -1,184 +0,0 @@ ---- -id: qingwa -name: Qingwa (青蛙) -description: "Qingwa (青蛙) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.qingwapt.com/ -legacylinks: - - https://qingwapt.com/ - - https://new.qingwa.pro/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影"} - - {id: 402, cat: TV, desc: "剧集"} - - {id: 403, cat: TV, desc: "综艺"} - - {id: 405, cat: TV/Anime, desc: "动漫"} - - {id: 404, cat: TV/Documentary, desc: "纪录片"} - - {id: 406, cat: Audio/Video, desc: "MV"} - - {id: 407, cat: TV/Sport, desc: "体育"} - - {id: 408, cat: Audio, desc: "音乐"} - - {id: 412, cat: TV, desc: "短剧"} # trailers - - {id: 412, cat: Movies, desc: "短剧"} # trailers - - {id: 409, cat: Other, desc: "其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 60 on your account profile.
Choosing more may cause the web site to fail to deliver complete results. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # switched from the form method to the cookie method due to #15226 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.15 2024-12-10 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/r3vwtf.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/r3vwtf.yml deleted file mode 100644 index a8e5de3a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/r3vwtf.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -id: r3vwtf -name: R3V WTF! -description: "R3V WTF! is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://r3vuk.wtf/ - -caps: - categorymappings: - - {id: 1, cat: PC, desc: "Apps"} - - {id: 39, cat: Other, desc: "AutoBoT"} - - {id: 36, cat: Movies/BluRay, desc: "Bluray"} - - {id: 34, cat: Audio/MP3, desc: "Carrow's MP3 Packs"} - - {id: 38, cat: Audio, desc: "DMC Music"} - - {id: 24, cat: Books/EBook, desc: "EBOOK"} - - {id: 37, cat: Audio, desc: "Mastermix Music"} - - {id: 20, cat: Movies/DVD, desc: "Movies/Dvdr"} - - {id: 17, cat: Movies, desc: "Movies/Packs"} - - {id: 26, cat: Movies/HD, desc: "Movies/X264"} - - {id: 44, cat: Movies/HD, desc: "Movies/x265"} - - {id: 10, cat: Movies/SD, desc: "Movies/XviD"} - - {id: 46, cat: Movies/DVD, desc: "Multi DVD"} - - {id: 14, cat: Audio, desc: "Music"} - - {id: 40, cat: Audio/Video, desc: "Music Videos"} - - {id: 22, cat: Audio/Lossless, desc: "Music/Flac"} - - {id: 51, cat: PC/Games, desc: "PC GAMES"} - - {id: 53, cat: Audio, desc: "Promo Music"} - - {id: 23, cat: TV, desc: "Tv/Packs"} - - {id: 16, cat: TV/HD, desc: "Tv/x264"} - - {id: 49, cat: TV/HD, desc: "Tv/X265"} - - {id: 15, cat: TV/SD, desc: "Tv/Xvid"} - - {id: 42, cat: Audio, desc: "VIP MUSIC"} - - {id: 50, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive for more than 42 days are automatically deleted." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - submitme: "X" - error: - - selector: h2 - message: - selector: table tr td.text - test: - path: index.php - selector: a[href*="logout.php?hash_please="] - -search: - paths: - # https://r3vuk.wtf/browse.php?c1=1&c24=1&search=&searchin=title&incldead=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # title descr genre all - searchin: title - # 0 active 1 incldead 2 onlydead - incldead: 1 - # 1 yes 0 no - only_free: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[cellpadding="3"] > tbody > tr:has(a[href^="download.php?torrent="]) - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - poster: - selector: a[href^="details.php?id="] - attribute: onmouseover - filters: - - name: regexp - args: "src=\\\\'(.+?)\\\\'" - files: - selector: td:nth-last-child(8) - date_year: - # Jul 4 2019 08:04 AM - selector: td:nth-last-child(6):not(:contains("day")) - optional: true - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "MMM d yyyy hh:mm tt zzz" - date_day: - # Today 02:40 PM - # Yesterday 09:10 AM - selector: td:nth-last-child(6):contains("day") - optional: true - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - description: - case: - img[src="./pic/mod.gif"]: Verified - "*": Unverified - downloadvolumefactor: - case: - a.info:contains("Freeleech"): 0 - "*": 1 - uploadvolumefactor: - case: - img[src$="pic/doubleseed.gif"]: 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# U-232v3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/racing4everyone-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/racing4everyone-api.yml deleted file mode 100644 index 1d9702a3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/racing4everyone-api.yml +++ /dev/null @@ -1,210 +0,0 @@ ---- -id: racing4everyone-api -name: Racing4Everyone (API) -description: "Racing4Everyone (R4E) is a Private Torrent Tracker for RACING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://racing4everyone.eu/ -legacylinks: - - https://racing4everyone.jp/ - -caps: - categorymappings: - - {id: 72, cat: TV/Sport, desc: "AMA Pro Motocross"} - - {id: 73, cat: TV/Sport, desc: "AMA Supercross"} - - {id: 76, cat: TV/Sport, desc: "ARCA"} - - {id: 77, cat: TV/Sport, desc: "Blancpain GT"} - - {id: 1, cat: TV/Sport, desc: "BTCC"} - - {id: 60, cat: TV/Sport, desc: "Drag Racing"} - - {id: 3, cat: TV/Sport, desc: "DTM"} - - {id: 61, cat: TV/Sport, desc: "ERC"} - - {id: 81, cat: TV/Sport, desc: "Extreme E"} - - {id: 67, cat: TV/Sport, desc: "Formula 1"} - - {id: 22, cat: TV/Sport, desc: "Formula 2"} - - {id: 68, cat: TV/Sport, desc: "Formula 3"} - - {id: 23, cat: TV/Sport, desc: "Formula E"} - - {id: 62, cat: TV/Sport, desc: "Indycar Series"} - - {id: 26, cat: TV/Sport, desc: "MotoGP-2-3-E"} - - {id: 82, cat: TV/Sport, desc: "Motorcycle Speedway"} - - {id: 85, cat: TV/Sport, desc: "MXGP"} - - {id: 31, cat: TV/Sport, desc: "Nascar"} - - {id: 25, cat: TV/Sport, desc: "Other Bikes"} - - {id: 69, cat: TV/Sport, desc: "Other Cars"} - - {id: 78, cat: TV/Sport, desc: "Porsche Supercup"} - - {id: 32, cat: TV/Sport, desc: "Stock Car Brazil"} - - {id: 63, cat: TV/Sport, desc: "Supercars"} - - {id: 84, cat: TV/Sport, desc: "Super Formula"} - - {id: 83, cat: TV/Sport, desc: "Super GT"} - - {id: 80, cat: TV/Sport, desc: "Touring Cars"} - - {id: 57, cat: TV/Sport, desc: "WEC"} - - {id: 50, cat: TV/Sport, desc: "WRC"} - - {id: 56, cat: TV/Sport, desc: "WRX"} - - {id: 71, cat: TV/Sport, desc: "WSBK"} - - {id: 74, cat: TV/Sport, desc: "WTCR"} - - {id: 58, cat: TV/Sport, desc: "W Series"} - - {id: 55, cat: TV/Sport, desc: "Season Reviews"} - - {id: 66, cat: TV/Sport, desc: "Documentaries"} - - {id: 2, cat: TV/Sport, desc: "TV-Documentaries"} - - {id: 24, cat: TV/Sport, desc: "Misc"} - - {id: 70, cat: TV/Sport, desc: "Motorsport Movies"} - - {id: 79, cat: TV/Sport, desc: "TV Series"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Racing4Everyone account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "After 6 months without any log in or usage of your account, it will be pruned and can only be reactivated by a staff member. Make sure to keep your account active and do not use programs to keep your account active, as your account may still get pruned." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.1.7 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/racingforme.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/racingforme.yml deleted file mode 100644 index 566194f6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/racingforme.yml +++ /dev/null @@ -1,249 +0,0 @@ ---- -id: racingforme -name: RacingForMe -description: "Racing For Me (RFM) is a Private Torrent Tracker for RACING" -language: en-US -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.racingfor.me/ - -caps: - categorymappings: - - {id: 1000, cat: TV/Sport, desc: "Formula 1 - Full races"} - - {id: 1010, cat: TV/Sport, desc: "F1 Full Seasons"} - - {id: 1020, cat: TV/Sport, desc: "F1 Full Season 1950-1980"} - - {id: 1030, cat: TV/Sport, desc: "F1 Full Season 1981-1990"} - - {id: 1040, cat: TV/Sport, desc: "F1 Full Season 1991-2000"} - - {id: 1050, cat: TV/Sport, desc: "F1 Full Season 2001-2010"} - - {id: 1060, cat: TV/Sport, desc: "F1 Full Season 2011-2020"} - - {id: 1070, cat: TV/Sport, desc: "F1 Full Season 2011-2020 (HD)"} - - {id: 2000, cat: TV/Sport, desc: "Formula 1 - Highlights"} - - {id: 2010, cat: TV/Sport, desc: "F1 Highlights 1950-1980"} - - {id: 2020, cat: TV/Sport, desc: "F1 Highlights 1981-1990"} - - {id: 2030, cat: TV/Sport, desc: "F1 Highlights 1991-2000"} - - {id: 2040, cat: TV/Sport, desc: "F1 Highlights 2001-2010"} - - {id: 2050, cat: TV/Sport, desc: "F1 Highlights 2011-2020"} - - {id: 2060, cat: TV/Sport, desc: "F1 Highlights 2011-2020 (HD)"} - - {id: 3000, cat: TV/Sport, desc: "Formula 1 - FP and quali"} - - {id: 3010, cat: TV/Sport, desc: "F1 FP and quali 1950-1980"} - - {id: 3020, cat: TV/Sport, desc: "F1 FP and quali 1981-1990"} - - {id: 3030, cat: TV/Sport, desc: "F1 FP and quali 1991-2000"} - - {id: 3040, cat: TV/Sport, desc: "F1 FP and quali 2001-2010"} - - {id: 3050, cat: TV/Sport, desc: "F1 FP and quali 2011-2020"} - - {id: 3060, cat: TV/Sport, desc: "F1 FP and quali 2011-2020 (HD)"} - - {id: 4000, cat: TV/Sport, desc: "Formula 1 - Onboard"} - - {id: 4010, cat: TV/Sport, desc: "F1 Onboard 1950-1980"} - - {id: 4020, cat: TV/Sport, desc: "F1 Onboard 1981-1990"} - - {id: 4030, cat: TV/Sport, desc: "F1 Onboard 1991-2000"} - - {id: 4040, cat: TV/Sport, desc: "F1 Onboard 2001-2010"} - - {id: 4050, cat: TV/Sport, desc: "F1 Onboard 2011-2020"} - - {id: 5000, cat: TV/Sport, desc: "Formula 1 - Miscellaneous"} - - {id: 5010, cat: TV/Sport, desc: "F1 Misc Documentaries"} - - {id: 5020, cat: TV/Sport, desc: "F1 Misc Driver/team specials"} - - {id: 5030, cat: TV/Sport, desc: "F1 Misc Event specials"} - - {id: 5040, cat: TV/Sport, desc: "F1 Misc Other"} - - {id: 6000, cat: TV/Sport, desc: "Formula 1 - current season"} - - {id: 6010, cat: TV/Sport, desc: "F1 current season Free practices"} - - {id: 6020, cat: TV/Sport, desc: "F1 current season Qualifications"} - - {id: 6030, cat: TV/Sport, desc: "F1 current season Races"} - - {id: 6040, cat: TV/Sport, desc: "F1 current season Highlights"} - - {id: 6050, cat: TV/Sport, desc: "F1 current season Onboards"} - - {id: 6060, cat: TV/Sport, desc: "F1 current season Other"} - - {id: 7000, cat: TV/Sport, desc: "Formula 1 - current season (HD)"} - - {id: 7010, cat: TV/Sport, desc: "F1 current season (HD) Free practices"} - - {id: 7020, cat: TV/Sport, desc: "F1 current season (HD) Qualifications"} - - {id: 7030, cat: TV/Sport, desc: "F1 current season (HD) Races"} - - {id: 7040, cat: TV/Sport, desc: "F1 current season (HD) Highlights"} - - {id: 7050, cat: TV/Sport, desc: "F1 current season (HD) Other"} - - {id: 8000, cat: TV/Sport, desc: "Moto3 / Moto2 / MotoGP"} - - {id: 8010, cat: TV/Sport, desc: "Moto 125cc"} - - {id: 8020, cat: TV/Sport, desc: "Moto 250cc"} - - {id: 8030, cat: TV/Sport, desc: "Moto Moto3"} - - {id: 8040, cat: TV/Sport, desc: "Moto Moto2"} - - {id: 8050, cat: TV/Sport, desc: "Moto MotoGP"} - - {id: 9000, cat: TV/Sport, desc: "Motorbikes"} - - {id: 9010, cat: TV/Sport, desc: "Motorbikes AMA"} - - {id: 9020, cat: TV/Sport, desc: "Motorbikes World Supersport"} - - {id: 9030, cat: TV/Sport, desc: "Motorbikes World Superstock"} - - {id: 9040, cat: TV/Sport, desc: "Motorbikes WSBK"} - - {id: 9050, cat: TV/Sport, desc: "Motorbikes BSB"} - - {id: 9060, cat: TV/Sport, desc: "Motorbikes Other"} - - {id: 9070, cat: TV/Sport, desc: "Motorbikes MotoAmerica"} - - {id: 10000, cat: TV/Sport, desc: "Touring cars"} - - {id: 10010, cat: TV/Sport, desc: "Touring cars WTCC"} - - {id: 10020, cat: TV/Sport, desc: "Touring cars DTM"} - - {id: 10030, cat: TV/Sport, desc: "Touring cars V8 Supercars"} - - {id: 10040, cat: TV/Sport, desc: "Touring cars BTCC"} - - {id: 10050, cat: TV/Sport, desc: "Touring cars Porsche Supercup"} - - {id: 10060, cat: TV/Sport, desc: "Touring cars Other"} - - {id: 11000, cat: TV/Sport, desc: "Sports cars"} - - {id: 11010, cat: TV/Sport, desc: "Sports cars BSS"} - - {id: 11020, cat: TV/Sport, desc: "Sports cars Le Mans 24H"} - - {id: 11030, cat: TV/Sport, desc: "Sports cars WSC/WEC"} - - {id: 11040, cat: TV/Sport, desc: "Sports cars IMSA"} - - {id: 11050, cat: TV/Sport, desc: "Sports cars USC"} - - {id: 11070, cat: TV/Sport, desc: "Sports cars BES"} - - {id: 11080, cat: TV/Sport, desc: "Sports cars Super GT"} - - {id: 11090, cat: TV/Sport, desc: "Sports cars IMSA Pilot Challenge"} - - {id: 11060, cat: TV/Sport, desc: "Sports cars Other"} - - {id: 12000, cat: TV/Sport, desc: "American open-wheelers"} - - {id: 12010, cat: TV/Sport, desc: "American open-wheelers CCWS"} - - {id: 12020, cat: TV/Sport, desc: "American open-wheelers IRL"} - - {id: 12030, cat: TV/Sport, desc: "American open-wheelers CART"} - - {id: 12040, cat: TV/Sport, desc: "American open-wheelers Indycar Series"} - - {id: 12050, cat: TV/Sport, desc: "American open-wheelers Indy Lights"} - - {id: 12060, cat: TV/Sport, desc: "American open-wheelers Other"} - - {id: 13000, cat: TV/Sport, desc: "Rally"} - - {id: 13010, cat: TV/Sport, desc: "Rally WRC"} - - {id: 13020, cat: TV/Sport, desc: "Rally ERC"} - - {id: 13030, cat: TV/Sport, desc: "Rally BRC"} - - {id: 13040, cat: TV/Sport, desc: "Rally National series"} - - {id: 13050, cat: TV/Sport, desc: "Rally Enduro-rally"} - - {id: 13060, cat: TV/Sport, desc: "Rally Rally cross"} - - {id: 13070, cat: TV/Sport, desc: "Rally Other"} - - {id: 14000, cat: TV/Sport, desc: "NASCAR / Stockcars"} - - {id: 14010, cat: TV/Sport, desc: "NASCAR Sprint Cup"} - - {id: 14020, cat: TV/Sport, desc: "NASCAR XFINITY Series"} - - {id: 14030, cat: TV/Sport, desc: "NASCAR CW Trucks"} - - {id: 14040, cat: TV/Sport, desc: "NASCAR ARCA"} - - {id: 14050, cat: TV/Sport, desc: "NASCAR Other"} - - {id: 15000, cat: TV/Sport, desc: "F3000 / GP2 / Formula 2"} - - {id: 15010, cat: TV/Sport, desc: "F2 1985-1990"} - - {id: 15020, cat: TV/Sport, desc: "F2 1991-2000"} - - {id: 15030, cat: TV/Sport, desc: "F2 2001-2011"} - - {id: 15040, cat: TV/Sport, desc: "F2 2012-2019"} - - {id: 15050, cat: TV/Sport, desc: "F2 GP2 Asia"} - - {id: 16000, cat: TV/Sport, desc: "Single seaters"} - - {id: 16010, cat: TV/Sport, desc: "Single seaters Formula 2"} - - {id: 16020, cat: TV/Sport, desc: "Single seaters FIA F3"} - - {id: 16030, cat: TV/Sport, desc: "Single seaters GP3"} - - {id: 16040, cat: TV/Sport, desc: "Single seaters WSBR 2.0"} - - {id: 16050, cat: TV/Sport, desc: "Single seaters WSBR 3.5"} - - {id: 16060, cat: TV/Sport, desc: "Single seaters British F3"} - - {id: 16070, cat: TV/Sport, desc: "Single seaters Formula E"} - - {id: 16080, cat: TV/Sport, desc: "Single seaters Other"} - - {id: 17000, cat: TV/Sport, desc: "Other Photographs"} - - {id: 17010, cat: TV/Sport, desc: "Other Formula 1"} - - {id: 17020, cat: TV/Sport, desc: "Other Motorbikes"} - - {id: 17030, cat: TV/Sport, desc: "Other Touring cars"} - - {id: 17040, cat: TV/Sport, desc: "Other Rally"} - - {id: 17050, cat: TV/Sport, desc: "Other Sports cars"} - - {id: 17060, cat: TV/Sport, desc: "Other"} - - {id: 18000, cat: TV/Sport, desc: "Miscellaneous"} - - {id: 18010, cat: TV/Sport, desc: "Documentaries"} - - {id: 18020, cat: TV/Sport, desc: "TV shows"} - - {id: 18030, cat: TV/Sport, desc: "DVD covers"} - - {id: 18040, cat: TV/Sport, desc: "E-books"} - - {id: 18050, cat: TV/Sport, desc: "Other"} - - {id: 19000, cat: TV/Sport, desc: "Other"} - - {id: 19010, cat: TV/Sport, desc: "Drag racing"} - - {id: 19020, cat: TV/Sport, desc: "Truck racing"} - - {id: 19030, cat: TV/Sport, desc: "Off-road"} - - {id: 19040, cat: TV/Sport, desc: "Drifting"} - - {id: 19050, cat: TV/Sport, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: date_desc - options: - date_desc: "created desc" - date_asc: "created asc" - sd_desc: "seeders desc" - sd_asc: "seeders asc" - sz_desc: "size desc" - sz_asc: "size asc" - name_desc: "title desc" - name_asc: "title asc" - -login: - path: login - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - rememberme: on - redirect: "" - error: - - selector: div.front__error - test: - path: / - selector: a[href$="/logout"] - -search: - paths: - # only 20 results per page - # https://www.racingfor.me/search/*?c=-1&ob=&offset=20#results - - path: "search/{{ if .Keywords }}{{ .Keywords }}{{ else }}*{{ end }}" - - path: "search/{{ if .Keywords }}{{ .Keywords }}{{ else }}*{{ end }}" - inputs: - offset: 20 - - path: "search/{{ if .Keywords }}{{ .Keywords }}{{ else }}*{{ end }}" - inputs: - offset: 40 - keywordsfilters: - # S2020E02 Becomes `2020 Round 02` - - name: re_replace - args: ["(?i)S(\\d{1,4})E(\\d{1,2})", "$1 Round $2"] - inputs: - c: -1 - ob: "{{ .Config.sort }}" - - rows: - selector: div.row:has(div.torrent-item__download) -# selector: div.row:not(div[class^="mt-"]) - # dateheaders are available, sometimes. But when there are none at all, then all rows are lost to errors. So we have to use 'date now' :-( -# dateheaders: -# # Monday March 29th, 2021 -# selector: div.mt-4 -# filters: -# - name: re_replace -# args: ["st,|nd,|rd,|th,", ""] -# - name: dateparse -# args: "dddd MMMM d yyyy" - - fields: - category: - selector: div.torrent-item__category span a - attribute: href - filters: - - name: querystring - args: c - title: - selector: div.torrent-item__title a - download: - selector: div.torrent-item__download a - attribute: href - details: - selector: div.torrent-item__title a - attribute: href - size: - selector: div.data-size - seeders: - selector: div.data-seeders - leechers: - selector: div.data-leechers - grabs: - selector: div.data-completed - date: - text: now - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 -# crankShaft v2.0.1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/railgunpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/railgunpt.yml deleted file mode 100644 index 36f81278..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/railgunpt.yml +++ /dev/null @@ -1,209 +0,0 @@ ---- -id: railgunpt -name: RailgunPT -description: "RailgunPT is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://bilibili.download/ - -caps: - categorymappings: - - {id: 408, cat: Audio, desc: "音乐 / HQ Audio"} - - {id: 409, cat: Other, desc: "其他 / Misc"} - - {id: 407, cat: TV/Sport, desc: "体育 / Sports"} - - {id: 406, cat: Audio/Video, desc: "MV / Music Videos"} - - {id: 403, cat: TV, desc: "综艺 / TV Shows"} - - {id: 402, cat: TV, desc: "剧集 / TVSeries"} - - {id: 405, cat: TV/Anime, desc: "动漫 / Anime"} - - {id: 404, cat: TV/Documentary, desc: "纪录片 / Documentary"} - - {id: 420, cat: Other, desc: "下架视频备份"} - - {id: 419, cat: Books/Comics, desc: "漫画 / Comics"} - - {id: 412, cat: Console, desc: "游戏 / Games"} - - {id: 411, cat: Books/Other, desc: "学习 / Study"} - - {id: 410, cat: PC, desc: "软件 / Software"} - - {id: 401, cat: Movies, desc: "电影 / Movies"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the RailgunPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Users Level 5 and above will be retained forever
  2. Users of Level 4 and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 300 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 120 consecutive days
  5. Users with no traffic (i.e. upload/download data is 0) within 30 days after registration will have their accounts deleted.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rain.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rain.yml deleted file mode 100644 index 5cc80a72..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rain.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: rain -name: Rain (雨) -description: "Rain (雨) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://raingfh.top/ - -caps: - categorymappings: - - {id: 408, cat: Audio, desc: "音乐 / HQ Audio"} - - {id: 409, cat: Other, desc: "其他 / Misc"} - - {id: 407, cat: TV/Sport, desc: "体育竞技 / Sports"} - - {id: 406, cat: Audio/Video, desc: "MV / Music Videos"} - - {id: 403, cat: TV, desc: "综艺 / TV Shows"} - - {id: 402, cat: TV, desc: "电视剧 / TVSeries"} - - {id: 405, cat: TV/Anime, desc: "动漫 / Anime"} - - {id: 404, cat: TV/Documentary, desc: "纪录片 / Documentary"} - - {id: 401, cat: Movies, desc: "电影 / Movies"} - - {id: 410, cat: Other, desc: "其他 / Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 4000 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 3650 consecutive days
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rainbowtracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rainbowtracker.yml deleted file mode 100644 index 850a7263..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rainbowtracker.yml +++ /dev/null @@ -1,315 +0,0 @@ ---- -id: rainbowtracker -name: Rainbow Tracker -description: "Rainbow Tracker is a RUSSIAN Semi-Private Torrent Tracker for LGBTQ MOVIES / TV" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://gaytracker.ru/ - -caps: - categorymappings: - - {id: 3, cat: Movies, desc: "Художественные гей-фильмы"} - - {id: 67, cat: Movies/Foreign, desc: "Художественные гей-фильмы - Российское гей-кино"} - - {id: 66, cat: Movies, desc: "Художественные гей-фильмы - Ретро гей-фильмы"} - - {id: 65, cat: Movies/Foreign, desc: "Художественные гей-фильмы - Азиатские гей-фильмы"} - - {id: 63, cat: Movies, desc: "Художественные гей-фильмы - Короткометражные гей-фильмы"} - - {id: 74, cat: XXX/Other, desc: "Художественные гей-фильмы - Фильмы с элементами гей-Pron "} - - {id: 82, cat: Movies, desc: "Художественные гей-фильмы - Фильмы с элементами гей-темы"} - - {id: 4, cat: Movies, desc: "Художественные лесби-фильмы"} - - {id: 89, cat: Movies/Foreign, desc: "Художественные лесби-фильмы - Российское лесби-кино"} - - {id: 90, cat: Movies, desc: "Художественные лесби-фильмы - Ретро лесби-фильмы"} - - {id: 78, cat: Movies/Foreign, desc: "Художественные лесби-фильмы - Азиатское лесби-кино"} - - {id: 64, cat: Movies, desc: "Художественные лесби-фильмы - Короткометражные лесби-фильмы"} - - {id: 91, cat: XXX/Other, desc: "Художественные лесби-фильмы - Фильмы с элементами лесби-Pron "} - - {id: 88, cat: Movies, desc: "Художественные лесби-фильмы - Фильмы с элементами лесби-темы"} - - {id: 96, cat: Movies, desc: "Художественные фильмы Транс*тематики"} - - {id: 5, cat: Movies, desc: "Арт-хаус и авторское кино"} - - {id: 7, cat: TV/Documentary, desc: "Документальные фильмы"} - - {id: 76, cat: XXX/Other, desc: "Документальные фильмы - Фильмы с элементами Pron "} - - {id: 8, cat: TV/Anime, desc: "Мультфильмы, Аниме"} - - {id: 9, cat: XXX, desc: "Эротическое видео"} - - {id: 71, cat: XXX, desc: "Эротическое видео - Документальные фильмы"} - - {id: 72, cat: XXX, desc: "Эротическое видео - Нудизм-Натуризм"} - - {id: 11, cat: TV/Other, desc: "Шоу, Клипы, Музыка, Юмор и т.п."} - - {id: 6, cat: TV, desc: "Сериалы"} - - {id: 80, cat: TV, desc: "Сериалы - Эпизод (серия) с гей-темой"} - - {id: 87, cat: TV, desc: "Сериалы - Бесстыжие / Shameless "} - - {id: 77, cat: TV, desc: "Сериалы - В поиске / Looking "} - - {id: 58, cat: TV, desc: "Сериалы - Дрянные девчонки / Bad Girls "} - - {id: 56, cat: TV, desc: "Сериалы - Лузеры / Хор / Glee "} - - {id: 49, cat: TV, desc: "Сериалы - Физика или Химия"} - - {id: 44, cat: TV, desc: "Сериалы - Юг нигде / South of nowhere "} - - {id: 41, cat: TV, desc: "Сериалы - Охота на парней / Manhunt "} - - {id: 39, cat: TV, desc: "Сериалы - Секс в другом городе / Мир Л / The L Word "} - - {id: 37, cat: TV, desc: "Сериалы - Логово / The Lair "} - - {id: 34, cat: TV, desc: "Сериалы - Бухта Данте"} - - {id: 29, cat: TV, desc: "Сериалы - Ноев ковчег / Noah's Arc "} - - {id: 26, cat: TV, desc: "Сериалы - Маленькая Британия / Little Britain "} - - {id: 21, cat: TV, desc: "Сериалы - Молокососы / Skins. season "} - - {id: 15, cat: TV, desc: "Сериалы - Близкие друзья / Queer as folk "} - - {id: 25, cat: XXX/ImageSet, desc: "Фото, Картинки"} - - {id: 10, cat: XXX, desc: "Pron "} - - {id: 16, cat: XXX, desc: "Pron - Pron-Фильмы"} - - {id: 86, cat: XXX, desc: "Pron - Pron-Фильмы (азиатские)"} - - {id: 53, cat: XXX, desc: "Pron - Pron-Ролики"} - - {id: 20, cat: XXX, desc: "Pron - Pron-Би"} - - {id: 17, cat: XXX, desc: "Pron - Pron-Лесбо"} - - {id: 18, cat: XXX, desc: "Pron - Pron-Транс"} - - {id: 19, cat: XXX, desc: "Pron - Pron-Гетеро"} - - {id: 59, cat: XXX, desc: "Pron - Pron с переводом"} - - {id: 81, cat: XXX, desc: "Pron - Pron-Мультфильмы, Pron-Аниме"} - - {id: 33, cat: XXX/Other, desc: "Pron - Pron-Разное"} - - {id: 13, cat: Other, desc: "Разное"} - - {id: 95, cat: Other/Misc, desc: "Разное - Клипы, Трейлеры, Рекламма"} - - {id: 48, cat: Other/Misc, desc: "Разное - Фильмы, мультфильмы"} - - {id: 57, cat: Other/Misc, desc: "Разное - Сериалы"} - - {id: 54, cat: Other/Misc, desc: "Разное - Картинки"} - - {id: 55, cat: Other/Misc, desc: "Разное - Музыка"} - - {id: 94, cat: Other/Misc, desc: "Разное - Аудиокниги"} - - {id: 47, cat: Other/Misc, desc: "Разное - Книги"} - - {id: 46, cat: Other/Misc, desc: "Разное - Игры"} - - {id: 45, cat: Other/Misc, desc: "Разное - Программы"} - - {id: 14, cat: Other, desc: "Тестовый форум"} - - {id: 32, cat: Other, desc: "Мусорка"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: forum/login.php - method: form - form: div.mrg_16 > table > tbody - captcha: - type: image - selector: img[src^="includes/usercp_confirm.php?"] - input: login_code - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - autologin: 1 - error: - - selector: h4.warnColor1 - test: - path: forum/index.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - - path: forum/tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 1 - prev_a: 0 - prev_vote: 0 - prev_gold: 0 - prev_silver: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # show speed - ds: 0 - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - # title search - nm: "{{ .Keywords }}" - # search by partial word - allw: 1 - gold: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: tbody[id^="tor_"]:has(a[href^="./download.php?id="]) - - fields: - category: - selector: a.gen - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.genmed, a.seedmed - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a.genmed, a.seedmed - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - size: - selector: td:nth-child(7) - seeders: - selector: td.seedmed > b, td.icemed > b - filters: - - name: replace - args: ["X", "0"] - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(10) - date_time: - selector: td:last-child - filters: - - name: regexp - args: "(\\d{2}:\\d{2})" - date_date: - selector: td:last-child - filters: - - name: regexp - args: "(\\d{1,2}-\\w{3,4}-\\d{2})" - date: - text: "{{ .Result.date_time }} {{ .Result.date_date }}" - # auto adjusted by site account profile - filters: - - name: replace - args: ["Янв", "Jan"] - - name: replace - args: ["Фев", "Feb"] - - name: replace - args: ["Мар", "Mar"] - - name: replace - args: ["Апр", "Apr"] - - name: replace - args: ["Май", "May"] - - name: replace - args: ["Июн", "Jun"] - - name: replace - args: ["Июл", "Jul"] - - name: replace - args: ["Авг", "Aug"] - - name: replace - args: ["Сен", "Sep"] - - name: replace - args: ["Окт", "Oct"] - - name: replace - args: ["Ноя", "Nov"] - - name: replace - args: ["Дек", "Dec"] - - name: dateparse - args: "HH:mm d-MMM-yy" - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - img[src="images/tor_silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.3 - description: - selector: a.genmed, a.seedmed -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rastastugan-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rastastugan-api.yml deleted file mode 100644 index 2ff9c9a3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rastastugan-api.yml +++ /dev/null @@ -1,200 +0,0 @@ ---- -id: rastastugan-api -name: Rastastugan (API) -description: "Rastastugan is a NORDIC Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://rastastugan.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: PC, desc: "App"} - - {id: 5, cat: Console, desc: "Game"} - - {id: 7, cat: Audio/Audiobook, desc: "Audiobook"} - - {id: 8, cat: Books, desc: "Book"} - - {id: 9, cat: Audio/Other, desc: "Podcast"} - - {id: 10, cat: TV/Sport, desc: "Sport"} - - {id: 11, cat: Other, desc: "3D Printable"} - - {id: 6, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Rastastugan account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in to the website or the tracker must register activity from your Announce URL once every 90 days or your account will be disabled." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/redstartorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/redstartorrent.yml deleted file mode 100644 index 0f84643f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/redstartorrent.yml +++ /dev/null @@ -1,131 +0,0 @@ ---- -id: redstartorrent -replaces: - - rstorrent -name: Red Star Torrent -description: "Red Star Torrent (RST) is a POLISH Private Torrent Tracker for MOVIES / TV" -language: pl-PL -type: private -encoding: ISO-8859-2 -links: - - http://rstorrent.org.pl/ # site does not support https NET::ERR_CERT_AUTHORITY_INVALID - -caps: - categorymappings: - - {id: 34, cat: PC/0day, desc: "0-day"} - - {id: 15, cat: Movies/3D, desc: "3D"} - - {id: 23, cat: TV/Anime, desc: "Anime"} - - {id: 1, cat: PC, desc: "Aplikacje"} - - {id: 30, cat: Books/EBook, desc: "Ebooki"} - - {id: 20, cat: Movies/SD, desc: "Filmy/DVD-R"} - - {id: 5, cat: Movies/HD, desc: "Filmy/HD"} - - {id: 19, cat: Movies/SD, desc: "Filmy/XviD"} - - {id: 4, cat: PC/Games, desc: "Gry/PC ISO"} - - {id: 28, cat: Other, desc: "GSM/PDA"} - - {id: 29, cat: Movies, desc: "Kids"} - - {id: 40, cat: Movies/Foreign, desc: "Kino Polska"} - - {id: 6, cat: Audio, desc: "Muzyka"} - - {id: 12, cat: TV, desc: "Paczka"} - - {id: 25, cat: Other, desc: "Rozne"} - - {id: 7, cat: TV, desc: "Seriale - Epizody"} - - {id: 3, cat: TV, desc: "Seriale - Sezony"} - - {id: 35, cat: TV, desc: "SHOW"} - - {id: 26, cat: TV/Sport, desc: "Sport"} - - {id: 36, cat: Other, desc: "Teatr"} - - {id: 27, cat: Audio/Video, desc: "Teledyski"} - - {id: 31, cat: TV/Documentary, desc: "TV Doc"} - - {id: 9, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("failed")) - - selector: td.embedded:has(h2:contains("Error")) - test: - path: browse.php - selector: a[href^="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - incldead: 1 - polish: 0 - - rows: - selector: table[border="1"][cellpadding="5"] > tbody > tr:has(a[href^="/details.php?id="]) - - fields: - title: - selector: a[href^="/details.php?id="] - details: - selector: a[href^="/details.php?id="] - attribute: href - category: - selector: a[href^="/browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - download: - selector: a[href^="/download.php/"] - attribute: href - description: - selector: img[src="/pic/pl.gif"] - attribute: src - filters: - - name: append - args: "Language: polish\n
" - - name: prepend - args: "{{ .Result.description }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-child(5) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - grabs: - selector: td:nth-child(7) - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(8) - filters: - - name: regexp - args: ^(\d+) - leechers: - selector: td:nth-child(8) - filters: - - name: regexp - args: / (\d+) - downloadvolumefactor: - case: - td.darmowy: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/reelflix-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/reelflix-api.yml deleted file mode 100644 index 635f3b04..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/reelflix-api.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: reelflix-api -replaces: - - legacyhd - - reelflix -name: ReelFLiX (API) -description: "ReelFLiX (HD4Free, LegacyHD) is a Private Torrent Tracker for HD MOVIES" -language: en-US -type: private -encoding: UTF-8 -links: - - https://reelflix.cc/ -legacylinks: - - https://legacyhd.org/ - - https://reelflix.xyz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - modes: - search: [q] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your ReelFlix account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To avoid your account being disabled, you must login at least once every 90 days OR have a .torrent actively announcing to the tracker. Please be aware! If you are using any form of automation and it has been 90+ days since your last login, and your client stops seeding, your account will be immediately disabled at midnight UTC if you don't login or resume seeding. There is no 90-day grace period if seeding stops; only logging into the site counts. It is always a good idea to periodically login to check the status of your account. After being disabled for inactivity you must make an attempt to recover your account within 90 days (180 days total) or it will be pruned from our system automatically. Pruned accounts CANNOT be enabled and you will have to reapply or find a new invite" - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - noResultsMessage: "No Torrents Found" - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - downloadvolumefactor: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - uploadvolumefactor: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - minimumratio: - text: 0.8 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rgfootball.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rgfootball.yml deleted file mode 100644 index 5ecab767..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rgfootball.yml +++ /dev/null @@ -1,627 +0,0 @@ ---- -id: rgfootball -name: RGFootball -description: "RGFootball is a RUSSIAN Sports torrent tracker." -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - http://rgfootball.net/ # the site does not support https - -caps: - categorymappings: - # VIP-Архив / VIP-Archive - - {id: 224, cat: TV/Sport, desc: " |- VIP January 2025"} - - {id: 225, cat: TV/Sport, desc: " |- VIP February 2025"} - - {id: 402, cat: TV/Sport, desc: " |- VIP March 2025"} - - {id: 403, cat: TV/Sport, desc: " |- VIP April 2025"} - - {id: 408, cat: TV/Sport, desc: " |- VIP May 2025"} - - {id: 409, cat: TV/Sport, desc: " |- VIP June 2025"} - - {id: 436, cat: TV/Sport, desc: " |- VIP July 2025"} - - {id: 438, cat: TV/Sport, desc: " |- VIP August 2025"} - - {id: 448, cat: TV/Sport, desc: " |- VIP September 2025"} - - {id: 449, cat: TV/Sport, desc: " |- VIP October 2025"} - - {id: 450, cat: TV/Sport, desc: " |- VIP November 2025"} - - {id: 451, cat: TV/Sport, desc: " |- VIP December 2025"} - - {id: 235, cat: TV/Sport, desc: " |- VIP January 2024"} - - {id: 247, cat: TV/Sport, desc: " |- VIP February 2024"} - - {id: 250, cat: TV/Sport, desc: " |- VIP March 2024"} - - {id: 252, cat: TV/Sport, desc: " |- VIP April 2024"} - - {id: 273, cat: TV/Sport, desc: " |- VIP May 2024"} - - {id: 275, cat: TV/Sport, desc: " |- VIP June 2024"} - - {id: 375, cat: TV/Sport, desc: " |- VIP July 2024"} - - {id: 378, cat: TV/Sport, desc: " |- VIP August 2024"} - - {id: 382, cat: TV/Sport, desc: " |- VIP September 2024"} - - {id: 384, cat: TV/Sport, desc: " |- VIP October 2024"} - - {id: 385, cat: TV/Sport, desc: " |- VIP November 2024"} - - {id: 386, cat: TV/Sport, desc: " |- VIP December 2024"} - # Олимпийские игры / Olympic Games - - {id: 464, cat: TV/Sport, desc: "Летние Олимпийские игры / 2024 /..."} - - {id: 440, cat: TV/Sport, desc: " |- VIP-Extra Olympics 2024"} - - {id: 486, cat: TV/Sport, desc: " |- Церемонии"} - - {id: 487, cat: TV/Sport, desc: " |- Футбол"} - - {id: 489, cat: TV/Sport, desc: " |- Баскетбол"} - - {id: 491, cat: TV/Sport, desc: " |- Волейбол"} - - {id: 500, cat: TV/Sport, desc: " |- Гандбол"} - - {id: 488, cat: TV/Sport, desc: " |- Теннис"} - - {id: 490, cat: TV/Sport, desc: " |- Бокс"} - - {id: 331, cat: TV/Sport, desc: " |- Лёгкая атлетика"} - - {id: 330, cat: TV/Sport, desc: " |- Тяжёлая атлетика"} - - {id: 329, cat: TV/Sport, desc: " |- Водные виды спорта и гребля"} - - {id: 323, cat: TV/Sport, desc: " |- Велоспорт"} - - {id: 324, cat: TV/Sport, desc: " |- Гимнастика"} - - {id: 327, cat: TV/Sport, desc: " |- Борьба и единоборства"} - - {id: 292, cat: TV/Sport, desc: " |- Другие виды спорта"} - - {id: 304, cat: TV/Sport, desc: " |- Обзорные и тематические..."} - - {id: 431, cat: TV/Sport, desc: " |- Архив - Олимпиады прошлых лет"} - - {id: 465, cat: TV/Sport, desc: "Зимние Олимпийские игры 2022 /..."} - - {id: 473, cat: TV/Sport, desc: " |- Церемонии"} - - {id: 483, cat: TV/Sport, desc: " |- Биатлон"} - - {id: 485, cat: TV/Sport, desc: " |- Фигурное катание"} - - {id: 467, cat: TV/Sport, desc: " |- Лыжный спорт"} - - {id: 474, cat: TV/Sport, desc: " |- Хоккей"} - - {id: 475, cat: TV/Sport, desc: " |- Конькобежный спорт/Шорт-трек"} - - {id: 484, cat: TV/Sport, desc: " |- Бобслей/Санный спорт/Скелетон"} - - {id: 481, cat: TV/Sport, desc: " |- Сноуборд/Фристайл/Кёрлинг"} - - {id: 482, cat: TV/Sport, desc: " |- Обзорные и тематические..."} - - {id: 476, cat: TV/Sport, desc: " |- Архив - Олимпиады прошлых лет"} - # Футбол / Football - - {id: 141, cat: TV/Sport, desc: "2026 FIFA World Cup / Чемпионат мира 2026"} - - {id: 556, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA WC 2026"} - - {id: 557, cat: TV/Sport, desc: " |- ⚽️VIP FIFA WC 2026"} - - {id: 525, cat: TV/Sport, desc: " |- ⚽️VIP-Light FIFA WC 2026"} - - {id: 157, cat: TV/Sport, desc: " |- FIFA WC 2026"} - - {id: 579, cat: TV/Sport, desc: " |- FIFA WC 2026. Qualifiers"} - - {id: 106, cat: TV/Sport, desc: "2025 FIFA Club World Cup / Клубный чемпиона..."} - - {id: 554, cat: TV/Sport, desc: " |- ⚽-Extra 2025 FIFA Club World Cup"} - - {id: 205, cat: TV/Sport, desc: " |- ️VIP-Archive 2025 FIFA Club World Cup"} - - {id: 92, cat: TV/Sport, desc: " |- 2025 FIFA Club World Cup"} - - {id: 163, cat: TV/Sport, desc: "EURO 2024 / Чемпионат Европы 2024"} - - {id: 132, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4K EURO 2024"} - - {id: 508, cat: TV/Sport, desc: " |- ⚽️VIP-Archive EURO 2024"} - - {id: 513, cat: TV/Sport, desc: " |- UEFA EURO 2024"} - - {id: 198, cat: TV/Sport, desc: " |- VIP UEFA Euro 2024 qualifying"} - - {id: 195, cat: TV/Sport, desc: " |- UEFA Euro 2024 qualifying"} - - {id: 580, cat: TV/Sport, desc: "2022 FIFA World Cup / Чемпионат мира 2022"} - - {id: 515, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA SATFEEDS WC 2022"} - - {id: 234, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA 4K WC 2022"} - - {id: 583, cat: TV/Sport, desc: " |- ⚽️VIP-Archive FIFA WC 2022"} - - {id: 581, cat: TV/Sport, desc: " |- FIFA WC 2022"} - - {id: 582, cat: TV/Sport, desc: " |- FIFA WC 2022. Qualifiers"} - - {id: 432, cat: TV/Sport, desc: "2018 FIFA World Cup / Чемпионат мира 2018"} - - {id: 535, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA WC 2018 4K"} - - {id: 559, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA WC 2018 Sat Feed"} - - {id: 574, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA WC 2018"} - - {id: 185, cat: TV/Sport, desc: " |- FIFA WC 2018"} - - {id: 136, cat: TV/Sport, desc: " |- FIFA WC 2018. Qualifiers"} - - {id: 217, cat: TV/Sport, desc: " |- FIFA WC 2018. Reviews"} - - {id: 458, cat: TV/Sport, desc: " |- VIP-Extra Archive FIFA WC 2018 Qualifiers"} - - {id: 578, cat: TV/Sport, desc: "2025-26 UEFA Champions League"} - - {id: 13, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4k + Sat Feed UCL"} - - {id: 511, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UCL 2025-26"} - - {id: 425, cat: TV/Sport, desc: " |- ⚽️VIP UCL 2025-26"} - - {id: 416, cat: TV/Sport, desc: " |- VIP-Light UCL 2025-26"} - - {id: 340, cat: TV/Sport, desc: " |- Qualifying Rounds"} - - {id: 342, cat: TV/Sport, desc: " |- Group stage"} - - {id: 344, cat: TV/Sport, desc: " |- Play-off"} - - {id: 339, cat: TV/Sport, desc: "2025-26 UEFA Europa League"} - - {id: 12, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UEL 2025-26"} - - {id: 343, cat: TV/Sport, desc: " |- Group stage"} - - {id: 341, cat: TV/Sport, desc: " |- Qualifying Rounds"} - - {id: 345, cat: TV/Sport, desc: " |- Play-off"} - - {id: 315, cat: TV/Sport, desc: "2025-26 UEFA Europa Conference League"} - - {id: 419, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 2025-26 UEFA ECL"} - - {id: 114, cat: TV/Sport, desc: " |- Qualifying Rounds"} - - {id: 165, cat: TV/Sport, desc: " |- Group stage"} - - {id: 164, cat: TV/Sport, desc: " |- Play-off"} - - {id: 452, cat: TV/Sport, desc: "2024-25 UEFA Champions League"} - - {id: 558, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4k + Sat Feed UCL"} - - {id: 346, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UCL 2024-25"} - - {id: 415, cat: TV/Sport, desc: " |- ⚽️VIP UCL 2024-25"} - - {id: 569, cat: TV/Sport, desc: " |- VIP-Light UCL 2024-25"} - - {id: 158, cat: TV/Sport, desc: " |- Qualifying Rounds"} - - {id: 159, cat: TV/Sport, desc: " |- Group stage"} - - {id: 232, cat: TV/Sport, desc: " |- Play-off"} - - {id: 160, cat: TV/Sport, desc: "2024-25 UEFA Europa League"} - - {id: 347, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UEL 2024-25"} - - {id: 512, cat: TV/Sport, desc: " |- ⚽️VIP UEL 2024-25"} - - {id: 570, cat: TV/Sport, desc: " |- VIP-Light UEL 2024-25"} - - {id: 161, cat: TV/Sport, desc: " |- Qualifying Rounds"} - - {id: 162, cat: TV/Sport, desc: " |- Group stage"} - - {id: 231, cat: TV/Sport, desc: " |- Play-off"} - - {id: 48, cat: TV/Sport, desc: "2024–25 UEFA Europa Conference League"} - - {id: 521, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UEFA ECL"} - - {id: 442, cat: TV/Sport, desc: " |- ⚽️VIP 2024-25 UEFA ECL"} - - {id: 517, cat: TV/Sport, desc: " |- VIP-Light 2024-25 UEFA ECL"} - - {id: 405, cat: TV/Sport, desc: " |- Qualifying Rounds"} - - {id: 536, cat: TV/Sport, desc: " |- Group stage"} - - {id: 249, cat: TV/Sport, desc: " |- Play-off"} - - {id: 3, cat: TV/Sport, desc: "Англия/England"} - - {id: 407, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4k England"} - - {id: 561, cat: TV/Sport, desc: " |- ⚽️VIP-Extra EPL Classic"} - - {id: 223, cat: TV/Sport, desc: " |- ⚽️VIP England"} - - {id: 553, cat: TV/Sport, desc: " |- VIP-Light England"} - - {id: 538, cat: TV/Sport, desc: " |- Premier League 2025-26"} - - {id: 412, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 418, cat: TV/Sport, desc: " |- Championship+ 2025-26"} - - {id: 54, cat: TV/Sport, desc: " |- Premier League 2024-25"} - - {id: 351, cat: TV/Sport, desc: " |- Championship+"} - - {id: 53, cat: TV/Sport, desc: " |- National Cups up to 2026"} - - {id: 55, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 2, cat: TV/Sport, desc: "Россия/СССР - Russia/USSR"} - - {id: 381, cat: TV/Sport, desc: " |- VIP Russia"} - - {id: 562, cat: TV/Sport, desc: " |- VIP-Light Russia"} - - {id: 251, cat: TV/Sport, desc: " |- RPL 2025-26"} - - {id: 423, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 496, cat: TV/Sport, desc: " |- FNL 2025-26"} - - {id: 57, cat: TV/Sport, desc: " |- RPL 2024-25"} - - {id: 56, cat: TV/Sport, desc: " |- National Cups up to 2025"} - - {id: 46, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 8, cat: TV/Sport, desc: "Украина/Ukraine"} - - {id: 563, cat: TV/Sport, desc: " |- VIP-Light Ukraine"} - - {id: 410, cat: TV/Sport, desc: " |- UPL 2025-26"} - - {id: 152, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 72, cat: TV/Sport, desc: " |- UPL 2024-25"} - - {id: 71, cat: TV/Sport, desc: " |- National Cups up to 2026"} - - {id: 348, cat: TV/Sport, desc: " |- Lower leagues"} - - {id: 70, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 4, cat: TV/Sport, desc: "Испания/Spain"} - - {id: 516, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4k + Sat Feed"} - - {id: 173, cat: TV/Sport, desc: " |- ⚽️VIP-Extra Spain Classic"} - - {id: 506, cat: TV/Sport, desc: " |- ⚽️VIP-Extra El Clasico"} - - {id: 208, cat: TV/Sport, desc: " |- ⚽️VIP Spain"} - - {id: 564, cat: TV/Sport, desc: " |- VIP-Light Spain"} - - {id: 194, cat: TV/Sport, desc: " |- Primera Division 2025-26"} - - {id: 424, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 58, cat: TV/Sport, desc: " |- Primera Division 2024-25"} - - {id: 59, cat: TV/Sport, desc: " |- National Cups up to 2025"} - - {id: 353, cat: TV/Sport, desc: " |- Segunda, Segunda B"} - - {id: 60, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 6, cat: TV/Sport, desc: "Германия/Germany"} - - {id: 555, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4k+ Germany"} - - {id: 210, cat: TV/Sport, desc: " |- ⚽️VIP Germany"} - - {id: 565, cat: TV/Sport, desc: " |- VIP-Light Germany"} - - {id: 175, cat: TV/Sport, desc: " |- Bundesliga 2025-26"} - - {id: 352, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 66, cat: TV/Sport, desc: " |- Bundesliga 2024-25"} - - {id: 355, cat: TV/Sport, desc: " |- 2. Bundesliga"} - - {id: 65, cat: TV/Sport, desc: " |- National Cups up to 2025"} - - {id: 427, cat: TV/Sport, desc: " |- Lower leagues"} - - {id: 64, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 5, cat: TV/Sport, desc: "Италия/Italy"} - - {id: 529, cat: TV/Sport, desc: " |- ⚽️VIP-Extra Italy"} - - {id: 209, cat: TV/Sport, desc: " |- ⚽️VIP Italy"} - - {id: 566, cat: TV/Sport, desc: " |- VIP-Light Italy"} - - {id: 174, cat: TV/Sport, desc: " |- Serie A 2025-26"} - - {id: 196, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 63, cat: TV/Sport, desc: " |- Serie A 2024-25"} - - {id: 354, cat: TV/Sport, desc: " |- Serie B, Lega Pro, Serie D"} - - {id: 62, cat: TV/Sport, desc: " |- National Cups up to 2026"} - - {id: 540, cat: TV/Sport, desc: " |- 1996-97 Juventus Classic"} - - {id: 61, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 7, cat: TV/Sport, desc: "Франция/France"} - - {id: 184, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4k + Sat Feed"} - - {id: 211, cat: TV/Sport, desc: " |- ⚽️VIP France"} - - {id: 567, cat: TV/Sport, desc: " |- VIP-Light France"} - - {id: 69, cat: TV/Sport, desc: " |- Ligue 1 2025-26"} - - {id: 133, cat: TV/Sport, desc: " |- National Cups 2025-26"} - - {id: 176, cat: TV/Sport, desc: " |- Ligue 1 2024-25"} - - {id: 428, cat: TV/Sport, desc: " |- Ligue 2"} - - {id: 68, cat: TV/Sport, desc: " |- National Cups up to 2026"} - - {id: 67, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 188, cat: TV/Sport, desc: "Нидерланды/Netherlands"} - - {id: 189, cat: TV/Sport, desc: " |- VIP-Extra Eredividie"} - - {id: 568, cat: TV/Sport, desc: " |- VIP Netherlands"} - - {id: 356, cat: TV/Sport, desc: " |- Season 2025-26"} - - {id: 190, cat: TV/Sport, desc: " |- National Cups"} - - {id: 191, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 9, cat: TV/Sport, desc: "Другие национальные чемпионаты..."} - - {id: 233, cat: TV/Sport, desc: " |- VIP-Extra Switzerland"} - - {id: 240, cat: TV/Sport, desc: " |- VIP-Extra Belgium"} - - {id: 531, cat: TV/Sport, desc: " |- VIP-Extra Portugal"} - - {id: 551, cat: TV/Sport, desc: " |- VIP-Extra Japan"} - - {id: 214, cat: TV/Sport, desc: " |- VIP-Extra Others"} - - {id: 549, cat: TV/Sport, desc: " |- VIP Others"} - - {id: 537, cat: TV/Sport, desc: " |- VIP-Light All Champs"} - - {id: 573, cat: TV/Sport, desc: " |- VIP-Extra Argentina"} - - {id: 394, cat: TV/Sport, desc: " |- Бразилия/Brazil"} - - {id: 441, cat: TV/Sport, desc: " |- Португалия/Portugal"} - - {id: 87, cat: TV/Sport, desc: " |- Европа/Europe"} - - {id: 91, cat: TV/Sport, desc: " |- Южная Америка/South America"} - - {id: 90, cat: TV/Sport, desc: " |- Северная Америка/North America"} - - {id: 89, cat: TV/Sport, desc: " |- Африка/Africa"} - - {id: 88, cat: TV/Sport, desc: " |- Азия/Asia"} - - {id: 179, cat: TV/Sport, desc: " |- Previous years matches"} - - {id: 11, cat: TV/Sport, desc: "Еврокубки/European Cups"} - - {id: 523, cat: TV/Sport, desc: " |- ⚽️⚽️⚽️VIP-Ultra Archive UEFA"} - - {id: 514, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UEFA Classic Finals"} - - {id: 376, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UEFA Classic"} - - {id: 212, cat: TV/Sport, desc: " |- ⚽️VIP UEFA"} - - {id: 421, cat: TV/Sport, desc: " |- VIP-Light UEFA"} - - {id: 377, cat: TV/Sport, desc: " |- UEFA Super Cup"} - - {id: 74, cat: TV/Sport, desc: " |- UEFA Champions League"} - - {id: 75, cat: TV/Sport, desc: " |- UEFA Europa League"} - - {id: 411, cat: TV/Sport, desc: " |- UEFA Europa Conference League"} - - {id: 434, cat: TV/Sport, desc: " |- UEFA Youth League"} - - {id: 76, cat: TV/Sport, desc: " |- UEFA European Cup"} - - {id: 78, cat: TV/Sport, desc: " |- UEFA Cup"} - - {id: 79, cat: TV/Sport, desc: " |- Cup Winners Cup"} - - {id: 80, cat: TV/Sport, desc: " |- Other Compeitions"} - - {id: 328, cat: TV/Sport, desc: "Лига наций УЕФА / UEFA Nations League"} - - {id: 530, cat: TV/Sport, desc: " |- ⚽️VIP-Extra UEFA Nations League"} - - {id: 201, cat: TV/Sport, desc: " |- ⚽️VIP UEFA Nations League"} - - {id: 572, cat: TV/Sport, desc: " |- VIP-Light UEFA Nations League"} - - {id: 193, cat: TV/Sport, desc: " |- League A"} - - {id: 544, cat: TV/Sport, desc: " |- League B"} - - {id: 543, cat: TV/Sport, desc: " |- League C"} - - {id: 542, cat: TV/Sport, desc: " |- League D"} - - {id: 117, cat: TV/Sport, desc: " |- Play-off"} - - {id: 15, cat: TV/Sport, desc: "Чемпионаты Мира/FIFA World Cups"} - - {id: 461, cat: TV/Sport, desc: " |- ⚽️VIP-Extra FIFA WC Classic"} - - {id: 443, cat: TV/Sport, desc: " |- VIP-Extra FIFA World Cup"} - - {id: 456, cat: TV/Sport, desc: " |- FIFA WC 2014. Финальный турнир"} - - {id: 168, cat: TV/Sport, desc: " |- FIFA WC 2014. Отборочный турнир"} - - {id: 495, cat: TV/Sport, desc: " |- FIFA WC 2014. Обзорные передачи"} - - {id: 178, cat: TV/Sport, desc: " |- FIFA WC U20"} - - {id: 153, cat: TV/Sport, desc: " |- FIFA WC U17"} - - {id: 218, cat: TV/Sport, desc: " |- VIP-Extra FIFA WC Old"} - - {id: 14, cat: TV/Sport, desc: "Чемпионаты Европы/UEFA European..."} - - {id: 242, cat: TV/Sport, desc: " |- ⚽️VIP-Extra EURO Classic"} - - {id: 546, cat: TV/Sport, desc: " |- VIP-Extra EURO 2024 U17 + U19 + U21"} - - {id: 528, cat: TV/Sport, desc: " |- ⚽️VIP-Extra 4K EURO 2020"} - - {id: 216, cat: TV/Sport, desc: " |- ⚽️VIP-Archive UEFA EURO 2020"} - - {id: 380, cat: TV/Sport, desc: " |- ⚽️VIP-Extra EURO 2016"} - - {id: 395, cat: TV/Sport, desc: " |- UEFA EURO 2020"} - - {id: 447, cat: TV/Sport, desc: " |- UEFA EURO 2020 qualifying"} - - {id: 552, cat: TV/Sport, desc: " |- EURO 2016. Финальный турнир"} - - {id: 494, cat: TV/Sport, desc: " |- EURO 2016. Отборочный турнир"} - - {id: 350, cat: TV/Sport, desc: " |- EURO 2012. Финальный турнир"} - - {id: 118, cat: TV/Sport, desc: " |- EURO 2012. Отборочный турнир"} - - {id: 200, cat: TV/Sport, desc: " |- EURO U21"} - - {id: 337, cat: TV/Sport, desc: " |- EURO U19"} - - {id: 255, cat: TV/Sport, desc: " |- EURO U17"} - - {id: 426, cat: TV/Sport, desc: " |- VIP-Extra UEFA EURO 2020 qualifying"} - - {id: 414, cat: TV/Sport, desc: "Международные клубные турниры/I..."} - - {id: 533, cat: TV/Sport, desc: " |- VIP-Extra Clubs International"} - - {id: 243, cat: TV/Sport, desc: " |- VIP Clubs International"} - - {id: 539, cat: TV/Sport, desc: " |- VIP-Light Clubs International"} - - {id: 534, cat: TV/Sport, desc: " |- VIP-Extra 2023 CONCACAF Gold Cup"} - - {id: 401, cat: TV/Sport, desc: " |- ⚽️VIP-Extra Copa Libertadores 2025"} - - {id: 526, cat: TV/Sport, desc: " |- ⚽️VIP-Extra Copa Sudamericana 2025"} - - {id: 399, cat: TV/Sport, desc: " |- ⚽️VIP-Extra CAF Competitions"} - - {id: 550, cat: TV/Sport, desc: " |- ⚽️VIP-Extra AFC Competitions"} - - {id: 524, cat: TV/Sport, desc: " |- VIP-Extra CONCACAF Competitions"} - - {id: 463, cat: TV/Sport, desc: " |- 2025 Copa Libertadores"} - - {id: 433, cat: TV/Sport, desc: " |- 2025 Copa Sudamericana"} - - {id: 457, cat: TV/Sport, desc: " |- FIFA Club World Cup / FIFA Intercontinental Cup"} - - {id: 472, cat: TV/Sport, desc: " |- CONCACAF Competitions"} - - {id: 241, cat: TV/Sport, desc: " |- Copa Libertadores"} - - {id: 367, cat: TV/Sport, desc: " |- Copa Sudamericana"} - - {id: 390, cat: TV/Sport, desc: " |- AFC Club Competitions"} - - {id: 417, cat: TV/Sport, desc: " |- CAF Competitions"} - - {id: 10, cat: TV/Sport, desc: "Международные турниры/National Team..."} - - {id: 466, cat: TV/Sport, desc: " |- VIP-Extra International"} - - {id: 213, cat: TV/Sport, desc: " |- VIP International"} - - {id: 527, cat: TV/Sport, desc: " |- VIP-Extra 2024 Copa America"} - - {id: 560, cat: TV/Sport, desc: " |- VIP 2024 Copa America"} - - {id: 202, cat: TV/Sport, desc: " |- VIP-Light 2024 Copa America"} - - {id: 575, cat: TV/Sport, desc: " |- 2024 Copa America"} - - {id: 532, cat: TV/Sport, desc: " |- 2023 Africa Cup of Nations"} - - {id: 422, cat: TV/Sport, desc: " |- 2023 AFC Asian Cup"} - - {id: 155, cat: TV/Sport, desc: " |- Copa America"} - - {id: 435, cat: TV/Sport, desc: " |- CONCACAF Competitions"} - - {id: 406, cat: TV/Sport, desc: " |- FIFA Confederations Cups"} - - {id: 239, cat: TV/Sport, desc: " |- Africa Cup of Nations"} - - {id: 507, cat: TV/Sport, desc: " |- AFC Asian Cups"} - - {id: 393, cat: TV/Sport, desc: " |- Sudamericano"} - - {id: 462, cat: TV/Sport, desc: " |- VIP-Extra 2023 Africa Cup of Nations"} - - {id: 16, cat: TV/Sport, desc: "Товарищеские турниры и матчи/Frie..."} - - {id: 387, cat: TV/Sport, desc: " |- ⚽️VIP-Extra Friendlies"} - - {id: 219, cat: TV/Sport, desc: " |- VIP Friendlies"} - - {id: 571, cat: TV/Sport, desc: " |- VIP-Light Friendlies"} - - {id: 18, cat: TV/Sport, desc: "Мини-футбол/Футзал"} - - {id: 19, cat: TV/Sport, desc: "Пляжный футбол"} - - {id: 499, cat: TV/Sport, desc: "Женский футбол"} - - {id: 17, cat: TV/Sport, desc: "Обзорные и аналитические..."} - - {id: 37, cat: TV/Sport, desc: "Футбольное видео, прочее о..."} - - {id: 199, cat: TV/Sport, desc: " |- Видео для мобильных устройств"} - - {id: 86, cat: TV/Sport, desc: " |- Футбольная графика"} - - {id: 43, cat: TV/Sport, desc: "Аудиопередачи /Футбольное..."} - # Спорт / Sport - - {id: 23, cat: TV/Sport, desc: "Авто/мотоспорт"} - - {id: 131, cat: TV/Sport, desc: " |- Formula 1. Season 2025"} - - {id: 492, cat: TV/Sport, desc: " |- Formula 1. Season 2024"} - - {id: 400, cat: TV/Sport, desc: " |- Formula 1. Seasons until 2023"} - - {id: 21, cat: TV/Sport, desc: "Хоккей"} - - {id: 501, cat: TV/Sport, desc: " |- VIP-Extra ЧМ 2024 U-18 / IIHF WС 2024 U-18"} - - {id: 420, cat: TV/Sport, desc: " |- ЧМ 2024 / IIHF WС 2024"} - - {id: 207, cat: TV/Sport, desc: " |- КХЛ 2024/25"} - - {id: 391, cat: TV/Sport, desc: " |- NHL 2024/25"} - - {id: 445, cat: TV/Sport, desc: " |- NHL 2024/24"} - - {id: 444, cat: TV/Sport, desc: " |- КХЛ 2023/24"} - - {id: 151, cat: TV/Sport, desc: " |- NHL до 2024"} - - {id: 47, cat: TV/Sport, desc: " |- КХЛ до 2024"} - - {id: 460, cat: TV/Sport, desc: " |- World Championships until 2024"} - - {id: 206, cat: TV/Sport, desc: " |- European club championships"} - - {id: 222, cat: TV/Sport, desc: " |- Euro Hockey Tour"} - - {id: 49, cat: TV/Sport, desc: " |- International tournaments"} - - {id: 468, cat: TV/Sport, desc: " |- СССР - Канада"} - - {id: 20, cat: TV/Sport, desc: "Баскетбол"} - - {id: 50, cat: TV/Sport, desc: " |- NBA 2023-24"} - - {id: 453, cat: TV/Sport, desc: " |- NCAA"} - - {id: 204, cat: TV/Sport, desc: " |- WNBA"} - - {id: 150, cat: TV/Sport, desc: " |- NBA до 2023"} - - {id: 51, cat: TV/Sport, desc: " |- European club basketball"} - - {id: 197, cat: TV/Sport, desc: " |- World Championships. Men"} - - {id: 379, cat: TV/Sport, desc: " |- World Championships. Women"} - - {id: 446, cat: TV/Sport, desc: " |- European Championships"} - - {id: 171, cat: TV/Sport, desc: " |- International tournaments"} - - {id: 203, cat: TV/Sport, desc: " |- Women's basketball"} - - {id: 22, cat: TV/Sport, desc: "Теннис"} - - {id: 274, cat: TV/Sport, desc: " |- VIP-Extra Tennis"} - - {id: 227, cat: TV/Sport, desc: " |- Australian Open 2025"} - - {id: 248, cat: TV/Sport, desc: " |- Roland Garros 2025"} - - {id: 388, cat: TV/Sport, desc: " |- Wimbledon 2025"} - - {id: 459, cat: TV/Sport, desc: " |- US Open 2024"} - - {id: 505, cat: TV/Sport, desc: " |- ATP 1000 WT Masters 2023"} - - {id: 502, cat: TV/Sport, desc: " |- ATP 250/500 World Tour 2023"} - - {id: 504, cat: TV/Sport, desc: " |- WTA Tour 2023"} - - {id: 503, cat: TV/Sport, desc: " |- Team Season 2023"} - - {id: 128, cat: TV/Sport, desc: " |- Grand Slam Tournaments up to 2024"} - - {id: 127, cat: TV/Sport, desc: " |- ATP WT Masters 1000 up to 2022"} - - {id: 125, cat: TV/Sport, desc: " |- ATP World Tour up to 2022"} - - {id: 126, cat: TV/Sport, desc: " |- WTA Tour up to 2022"} - - {id: 130, cat: TV/Sport, desc: " |- Team Season up to 2022"} - - {id: 129, cat: TV/Sport, desc: " |- Matches of previous years"} - - {id: 44, cat: TV/Sport, desc: "Биатлон"} - - {id: 498, cat: TV/Sport, desc: " |- Season 2023"} - - {id: 455, cat: TV/Sport, desc: " |- Seasons until 2022"} - - {id: 82, cat: TV/Sport, desc: "Гандбол"} - - {id: 548, cat: TV/Sport, desc: " |- VIP-Extra Handball"} - - {id: 469, cat: TV/Sport, desc: " |- WC's and EURO's. Men"} - - {id: 547, cat: TV/Sport, desc: " |- WC's and EURO's. Women"} - - {id: 397, cat: TV/Sport, desc: " |- European club handball"} - - {id: 398, cat: TV/Sport, desc: " |- International tournaments"} - - {id: 38, cat: TV/Sport, desc: "Волейбол"} - - {id: 261, cat: TV/Sport, desc: " |- Лига наций"} - - {id: 262, cat: TV/Sport, desc: " |- Клубный волейбол"} - - {id: 170, cat: TV/Sport, desc: " |- Международные турниры"} - - {id: 169, cat: TV/Sport, desc: " |- Пляжный волейбол"} - - {id: 25, cat: TV/Sport, desc: "Боевые единоборства"} - - {id: 98, cat: TV/Sport, desc: " |- Бокс"} - - {id: 101, cat: TV/Sport, desc: " |- Рестлинг"} - - {id: 100, cat: TV/Sport, desc: " |- Mixed Martial Arts"} - - {id: 99, cat: TV/Sport, desc: " |- Ultimate Fighting Championship"} - - {id: 97, cat: TV/Sport, desc: " |- Боевые искусства"} - - {id: 137, cat: TV/Sport, desc: "Легкая атлетика"} - - {id: 139, cat: TV/Sport, desc: "Тяжелая атлетика"} - - {id: 122, cat: TV/Sport, desc: "Бильярд"} - - {id: 123, cat: TV/Sport, desc: " |- Снукер"} - - {id: 140, cat: TV/Sport, desc: "Велоспорт"} - - {id: 24, cat: TV/Sport, desc: "Зимние виды спорта"} - - {id: 142, cat: TV/Sport, desc: " |- Фигурное катание"} - - {id: 105, cat: TV/Sport, desc: " |- Лыжи"} - - {id: 104, cat: TV/Sport, desc: " |- Горные лыжи"} - - {id: 149, cat: TV/Sport, desc: " |- Фристайл и сноуборд"} - - {id: 103, cat: TV/Sport, desc: " |- Прыжки с трамплина"} - - {id: 148, cat: TV/Sport, desc: " |- Кёрлинг"} - - {id: 96, cat: TV/Sport, desc: " |- Всемирные зимние Универсиады"} - - {id: 192, cat: TV/Sport, desc: "Летние виды спорта"} - - {id: 143, cat: TV/Sport, desc: " |- Бадминтон"} - - {id: 144, cat: TV/Sport, desc: " |- Настольный теннис"} - - {id: 147, cat: TV/Sport, desc: " |- Гольф"} - - {id: 439, cat: TV/Sport, desc: " |- Всемирные летние Универсиады"} - - {id: 138, cat: TV/Sport, desc: "Водные виды спорта"} - - {id: 121, cat: TV/Sport, desc: "Бейсбол"} - - {id: 145, cat: TV/Sport, desc: "Американский футбол"} - - {id: 541, cat: TV/Sport, desc: " |- VIP-Extra American football"} - - {id: 26, cat: TV/Sport, desc: "Другие виды спорта"} - - {id: 146, cat: TV/Sport, desc: " |- Регби"} - - {id: 95, cat: TV/Sport, desc: "Информационно-аналитические..."} - - {id: 180, cat: TV/Sport, desc: "Разное о спорте"} - - {id: 182, cat: TV/Sport, desc: " |- Спортивная литература"} - - {id: 181, cat: TV/Sport, desc: " |- Спортивное видео"} - - {id: 183, cat: TV/Sport, desc: " |- Обучающее видео"} - # Летняя Олимпиада 2020 / Summer Olympic Games 2020 - - {id: 276, cat: TV/Sport, desc: "Football / Soccer"} - - {id: 277, cat: TV/Sport, desc: " |- VIP-Extra Football"} - - {id: 359, cat: TV/Sport, desc: " |- Men"} - - {id: 360, cat: TV/Sport, desc: " |- Women"} - - {id: 301, cat: TV/Sport, desc: "Basketball / 3x3 basketball"} - - {id: 522, cat: TV/Sport, desc: " |- VIP-Extra Basketball"} - - {id: 362, cat: TV/Sport, desc: " |- Men"} - - {id: 361, cat: TV/Sport, desc: " |- Women"} - - {id: 308, cat: TV/Sport, desc: "Handball"} - - {id: 245, cat: TV/Sport, desc: " |- VIP-Extra Handball"} - - {id: 366, cat: TV/Sport, desc: " |- Men"} - - {id: 365, cat: TV/Sport, desc: " |- Women"} - - {id: 305, cat: TV/Sport, desc: "Volleyball"} - - {id: 368, cat: TV/Sport, desc: " |- VIP-Extra Volleyball"} - - {id: 364, cat: TV/Sport, desc: " |- Men"} - - {id: 363, cat: TV/Sport, desc: " |- Women"} - - {id: 306, cat: TV/Sport, desc: " |- Beach volleyball"} - - {id: 325, cat: TV/Sport, desc: "Tennis"} - - {id: 520, cat: TV/Sport, desc: " |- VIP-Extra Tennis"} - - {id: 326, cat: TV/Sport, desc: " |- Table tennis"} - - {id: 316, cat: TV/Sport, desc: "Athletics"} - - {id: 291, cat: TV/Sport, desc: " |- VIP-Extra Athletics"} - - {id: 285, cat: TV/Sport, desc: " |- Men"} - - {id: 286, cat: TV/Sport, desc: " |- Women"} - - {id: 318, cat: TV/Sport, desc: "Weightlifting"} - - {id: 298, cat: TV/Sport, desc: " |- VIP-Extra Weightlifting"} - - {id: 288, cat: TV/Sport, desc: " |- Men"} - - {id: 289, cat: TV/Sport, desc: " |- Women"} - - {id: 278, cat: TV/Sport, desc: "Aquatics"} - - {id: 290, cat: TV/Sport, desc: " |- VIP-Extra Aquatics"} - - {id: 280, cat: TV/Sport, desc: " |- Swimming"} - - {id: 281, cat: TV/Sport, desc: " |- Diving"} - - {id: 282, cat: TV/Sport, desc: " |- Artistic swimming"} - - {id: 279, cat: TV/Sport, desc: " |- Water polo"} - - {id: 284, cat: TV/Sport, desc: " |- Canoeing - Rowing - Sailing - Surfing"} - - {id: 303, cat: TV/Sport, desc: "Boxing / Wrestling / Judo / Karate / Taekwondo"} - - {id: 518, cat: TV/Sport, desc: " |- VIP-Extra"} - - {id: 310, cat: TV/Sport, desc: "Gymnastics"} - - {id: 312, cat: TV/Sport, desc: " |- Artistic - Спортивная"} - - {id: 311, cat: TV/Sport, desc: " |- Rhythmic - Художественная"} - - {id: 313, cat: TV/Sport, desc: " |- Trampoline - Прыжки на батуте"} - - {id: 294, cat: TV/Sport, desc: "Cycling"} - - {id: 519, cat: TV/Sport, desc: " |- VIP-Extra Cycling"} - - {id: 296, cat: TV/Sport, desc: " |- Track cycling"} - - {id: 297, cat: TV/Sport, desc: " |- Road cycling"} - - {id: 299, cat: TV/Sport, desc: " |- Mountain biking - BMX freestyle - racing"} - - {id: 287, cat: TV/Sport, desc: "Конный спорт / Equestrian / Dressage /..."} - - {id: 322, cat: TV/Sport, desc: "Archery / Fencing / Shooting"} - - {id: 254, cat: TV/Sport, desc: " |- VIP-Extra"} - - {id: 333, cat: TV/Sport, desc: "Other sports"} - - {id: 371, cat: TV/Sport, desc: " |- VIP-Extra Field hockey"} - - {id: 335, cat: TV/Sport, desc: " |- Field hockey"} - - {id: 358, cat: TV/Sport, desc: " |- Badminton"} - - {id: 320, cat: TV/Sport, desc: " |- Rugby sevens"} - - {id: 283, cat: TV/Sport, desc: " |- Baseball - Softball"} - - {id: 319, cat: TV/Sport, desc: " |- Modern pentathlon - Triathlon"} - - {id: 336, cat: TV/Sport, desc: " |- Golf - Skateboarding - Sport climbing"} - - {id: 349, cat: TV/Sport, desc: "Обзорные передачи / Reviews"} - # Игры / Games - - {id: 166, cat: TV/Sport, desc: "Игры для PC (раздачи)"} - # Разное / Other - - {id: 229, cat: TV/Sport, desc: "Тестовый форум"} - # Администраторский - - {id: 497, cat: TV/Sport, desc: " |- VIP-Light UEFA EURO 2024"} - - {id: 383, cat: TV/Sport, desc: " |- 2023 FIFA U20 World Cup"} - # Приватные разделы - - {id: 509, cat: TV/Sport, desc: " |- VIP-Extra 2019 FIFA U-17 World Cup"} - - {id: 230, cat: TV/Sport, desc: " |- VIP-Extra Copa America 2021"} - - {id: 510, cat: TV/Sport, desc: " |- VIP 2019 FIFA U-17 World Cup"} - - {id: 429, cat: TV/Sport, desc: " |- VIP-Extra Real Madrid 2020-21"} - - {id: 369, cat: TV/Sport, desc: " |- VIP-Extra Sat Feed France"} - - modes: - search: [q] - tv-search: [q, season, ep] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: ignorevip - type: checkbox - label: Ignore VIP torrents - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: login.php - method: form - form: form[action$="/login.php"] - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - autologin: 1 - redirect: index.php - login: Вход - selectorinputs: - cookie_test: - selector: input[name="cookie_test"] - attribute: value - error: - - selector: h4.warnColor1 - test: - path: index.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - - path: tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 1 - prev_a: 0 - prev_not_checked: 0 - prev_closed: 0 - prev_d: 0 - prev_not_perfect: 0 - prev_part_perfect: 0 - prev_fishily: 0 - prev_copy: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - # sort by 1=registered 4=completed 10=seed 11=leech 7=size 8=lastPost - o: "{{ .Config.sort }}" - # 1=asc 2=desc - s: "{{ .Config.type }}" - # -1=all time - tm: -1 - nm: "{{ .Keywords }}" - - headers: - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: "tr[id^=\"tor_\"]:has(a[href^=\"./download.php?id=\"]){{ if .Config.ignorevip }}:not(:has(a.gen:contains(\"VIP\"))){{ else }}{{ end }}{{ if .Config.freeleech }}:has(img[src=\"images/tor_gold.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a.gen - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.tLink - details: - selector: a.tLink - attribute: href - description: - selector: a.gen - download: - selector: a[href^="./download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - # unix - selector: td:last-child > u - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rintor.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rintor.yml deleted file mode 100644 index e7f82a0c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rintor.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: rintor -name: RinTor -description: "RinTor is a Semi-Private Tracker for 3X" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://rintor.org/ - -caps: - categorymappings: - # Зарубежныепорнофильмы|Pornofilms - - {id: 71, cat: XXX, desc: "Фильмы UHD | 4K UHD (2160p) Movies"} - - {id: 26, cat: XXX, desc: "HD порнофильмы | HD Porn Movies"} - - {id: 20, cat: XXX, desc: "Гонзо Фильмы | Gonzo & All Sex"} - - {id: 22, cat: XXX, desc: "Лесбо Фильмы | Lesbo Movies"} - - {id: 23, cat: XXX, desc: "Этнические фильмы | Ethnic Movies"} - - {id: 21, cat: XXX, desc: "Фильмы с сюжетом | Feature"} - - {id: 64, cat: XXX, desc: "Эротические фильмы | Erotic Films"} - - {id: 25, cat: XXX, desc: "Классика | Classic Porn"} - # Порноролики|Porn Clips "> - - {id: 65, cat: XXX, desc: "Инцест и Табу (Инсценировка) | Incest and Taboo..."} - - {id: 28, cat: XXX, desc: "Сайтрипы HD | SiteRip's HD Video"} - - {id: 29, cat: XXX, desc: "Сайтрипы UHD | 4K UHD (2160p) Porn Video"} - - {id: 27, cat: XXX, desc: "Сайтрипы | SiteRip's"} - - {id: 31, cat: XXX, desc: "Сцены из фильмов | Movie Scenes"} - - {id: 32, cat: XXX, desc: "Порноролики Разное | Various Clips"} - - {id: 66, cat: XXX, desc: "Лесбо Порноролики | Lesbo Vids"} - - {id: 67, cat: XXX, desc: "Порно Кастинг | Porno Casting"} - - {id: 35, cat: XXX, desc: "Японское порно | Japan Porn"} - # Русскоепорно|Russian Porn "> - - {id: 36, cat: XXX, desc: "Русские порнофильмы | Russian Porn Films"} - - {id: 37, cat: XXX, desc: "Русские Порноролики Разное | Russian Clips (various)"} - - {id: 38, cat: XXX, desc: "Русское любительское порно видео | Russian Amateur Porn..."} - # ЭротическиестудиииФотосеты,Комиксы,Журналы|Erotic,Pictures,Magazines & Comix "> - - {id: 40, cat: XXX, desc: "Эротические студии Фото | Erotic Picture Gallery"} - - {id: 42, cat: XXX, desc: "Любительское фото | Amateur Picture"} - - {id: 44, cat: XXX, desc: "Подборки сетов | Picture Sets"} - - {id: 45, cat: XXX, desc: "Нетрадиционное порно фото"} - - {id: 46, cat: XXX, desc: "Комиксы и рисунки, Журналы | Magazines & Comix"} - # Нетрадиционноепорно|Special Interest Porn "> - - {id: 47, cat: XXX, desc: "Бисексуалы | Bisexual"} - - {id: 48, cat: XXX, desc: "Транссексуалы | Shemale Transsexual"} - - {id: 49, cat: XXX, desc: "БДСМ | BDSM"} - - {id: 50, cat: XXX, desc: "Женское доминирование и страпон | Femdom & Strapon"} - - {id: 51, cat: XXX, desc: "Подглядывание | Voyeur"} - - {id: 52, cat: XXX, desc: "Фистинг и дилдо | Fisting & Dildo"} - - {id: 53, cat: XXX, desc: "Беременные | Pregnant Preggo"} - - {id: 54, cat: XXX, desc: "Буккаке | Bukkake"} - - {id: 55, cat: XXX, desc: "Мочеиспускание | Peeing"} - - {id: 56, cat: XXX, desc: "Фетиш | Fetish"} - # Мультфильмы,Хентайиигры|Сartoons,Hentai & Games "> - - {id: 58, cat: XXX, desc: "Хентай"} - - {id: 59, cat: XXX, desc: "Игры"} - - {id: 60, cat: XXX, desc: "Порно Мультики"} - # Гей-порно|Gay porn "> - - {id: 61, cat: XXX, desc: "Полнометражные гей-фильмы | Full Length Gay Movies"} - - {id: 62, cat: XXX, desc: "Ролики, SiteRip'ы и сцены из гей-фильмов | Gay Clips..."} - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: login.php - method: post - inputs: - "login_username": "{{ .Config.username }}" - "login_password": "{{ .Config.password }}" - autologin: 1 - login: "Вход" - error: - - selector: table.error - test: - path: tracker.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - - path: tracker.php - method: post - inputs: - prev_allw: 1 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - prev_tor_type: 0 - # sort by 1=registered 2=topicTitle 4=completed 10=seed 11=leech 12=up 13=down 5=replies 6=views 7=size 8=lastPost 9=seedLastSeen - o: "{{ .Config.sort }}" - # 1=asc 2=desc - s: "{{ .Config.type }}" - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # show speed - ds: 0 - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - # group releases -1=not selected - srg: -1 - nm: "{{ .Keywords }}" - # find a username - pn: "" - # despite the all words flag the search engine returns anywords matching. - allw: 1 - $raw: "{{ if .Categories }}{{ range .Categories }}&f[]={{.}}{{end}}{{ else }}&f[]=-1{{ end }}" - keywordsfilters: - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: tr[id^="tor_"]:has(span.tor-approved) # only approved tors have .torrent and magnets. - filters: - - name: andmatch - - fields: - category: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: td a.tLink - details: - selector: td a.tLink - attribute: href - download: - selector: a[href^="./dl.php?id="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-child(6) u - grabs: - selector: td:nth-child(9) - seeders: - selector: td.seedmed - leechers: - selector: td.leechmed - date: - # unix - selector: td:nth-child(10) u - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rintornet.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rintornet.yml deleted file mode 100644 index e89644de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rintornet.yml +++ /dev/null @@ -1,105 +0,0 @@ ---- -id: rintornet -name: RinTor.NeT -description: "RinTor.NeT is a RUSSIAN Public tracker for 3X" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://www.rintor.net/ - -caps: - categorymappings: - - {id: 27, cat: XXX, desc: "Фильмы 2160 4K | Movies 2160p 4K"} - - {id: 20, cat: XXX, desc: "Сайтрипы 2160 4K | Porn Video 2160p 4K"} - - {id: 1, cat: XXX, desc: "Фильмы HD | Movies HD"} - - {id: 12, cat: XXX, desc: "Фильмы SD | Movies SD"} - - {id: 30, cat: XXX, desc: "Сайтрип HD | SiteRip HD"} - - {id: 9, cat: XXX, desc: "Сайтрип SD | SiteRip SD"} - - {id: 10, cat: XXX, desc: "Любительское видео | Amateur Video"} - - {id: 24, cat: XXX, desc: "Инцест (Инсценировка) | Incest (Simulation)"} - - {id: 22, cat: XXX, desc: "Лесбиянки | Lesbians"} - - {id: 26, cat: XXX, desc: "Порно Кастинг | Porno Casting"} - - {id: 29, cat: XXX, desc: "Японское (Без Цензуры) | Japanese Porn (Uncen)"} - - {id: 23, cat: XXX, desc: "Ретро Порно, Классика | Retro Porn, Classic"} - - {id: 21, cat: XXX, desc: "Женское Доминирование, Страпон | Femdom, StrapOn"} - - {id: 17, cat: XXX, desc: "БДСМ, Фистинг, Дилдо | BDSM, Fisting, Dildo"} - - {id: 19, cat: XXX, desc: "Беременные | Pregnant"} - - {id: 28, cat: XXX, desc: "Буккаке | Bukkake"} - - {id: 25, cat: XXX, desc: "Мочеиспускание | Peeing"} - - {id: 16, cat: XXX, desc: "Транссексуалы | Shemale Transsexual"} - - {id: 11, cat: XXX, desc: "Подборки сетов | Picture Sets Packs"} - - {id: 14, cat: XXX, desc: "Порно Мультфильмы | Porno Cartoons"} - - {id: 13, cat: XXX, desc: "Порно Игры | Porn Games"} - - {id: 15, cat: XXX, desc: "Гей и Бисекс Порно | Gay and Bisex Porn"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - paths: - - path: index.php - inputs: - do: search - subaction: search - story: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - titleonly: 3 - sortby: date - resorder: desc - showposts: 0 - $raw: "{{ range .Categories }}catlist[]={{.}}&{{end}}" - - rows: - selector: div.entry:has(div.entry__title) - - fields: - categorydesc: - selector: div.entry__info > span > a - title: - selector: div.entry__title > h3 > a - details: - selector: div.entry__title > h3 > a - attribute: href - download: - selector: div.entry__title > h3 > a - attribute: href - poster: - selector: div.entry_content a.entry__content-image img - attribute: src - date_day: - selector: span.entry__date:contains("Вчера"), span.entry__date:contains("Сегодня") - optional: true - filters: - - name: replace - args: ["Вчера", "Yesterday"] - - name: replace - args: ["Сегодня", "Today"] - - name: fuzzytime - date_year: - selector: span.entry__date:contains("-") - optional: true - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "d-MM-yyyy, HH:mm zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - size: - text: "1 GB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/riperam.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/riperam.yml deleted file mode 100644 index f815ffe2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/riperam.yml +++ /dev/null @@ -1,1031 +0,0 @@ ---- -id: riperam -name: RiperAM -description: "RiperAM is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://riper.online/ -legacylinks: - - http://riperam.org/ - - https://riperam.org/ - -caps: - categorymappings: - - {id: 238, cat: Movies, desc: "Новинки кино (2024-2025). Новые фильмы скачать бесплатно"} - - {id: 425, cat: Movies/HD, desc: "DVDRip / DVD-5/DVD-9"} - - {id: 50, cat: Movies/HD, desc: "DVDRip"} - - {id: 52, cat: Movies/HD, desc: "DVD-5/DVD-9"} - - {id: 424, cat: Movies/HD, desc: "BDRip / HDRip / BD / HD / Blu - ray / Ultra HD (4K)"} - - {id: 51, cat: Movies/HD, desc: "BDRip/HDRip"} - - {id: 371, cat: Movies/HD, desc: "BD / HD"} - - {id: 251, cat: Movies/BluRay, desc: "Blu-ray"} - - {id: 1404, cat: Movies/UHD, desc: "Ultra HD (4K)"} - - {id: 349, cat: Movies, desc: "Видео для мобильных устройств"} - - {id: 350, cat: Movies, desc: "Видео (iPhone, iPod Touch)"} - - {id: 351, cat: Movies, desc: "HD Видео (Apple TV, iPad)"} - - {id: 352, cat: Movies, desc: "Видео для смартфонов и КПК"} - - {id: 1497, cat: Movies, desc: "Новинки кино (2025). Новые фильмы скачать бесплатно"} - - {id: 239, cat: Movies, desc: "Фильмы, мультфильмы"} - - {id: 420, cat: Movies, desc: "Зарубежные фильмы"} - - {id: 12, cat: Movies, desc: "Зарубежные фильмы до 2000го года..."} - - {id: 13, cat: Movies, desc: "Зарубежные фильмы после 2000го года..."} - - {id: 1476, cat: Movies, desc: "Зарубежные фильмы Blu-Ray"} - - {id: 1475, cat: Movies, desc: "Зарубежные фильмы 4К"} - - {id: 423, cat: TV/Anime, desc: "Мультфильмы"} - - {id: 16, cat: TV/Anime, desc: "Зарубежные мультфильмы"} - - {id: 17, cat: TV/Anime, desc: "Советские мультфильмы"} - - {id: 18, cat: TV/Anime, desc: "Российские мультфильмы"} - - {id: 15, cat: TV/Anime, desc: "Российские фильмы"} - - {id: 14, cat: TV/Anime, desc: "Советские фильмы"} - - {id: 240, cat: TV, desc: "Сериалы, мультсериалы"} - - {id: 19, cat: TV, desc: "Зарубежные сериалы"} - - {id: 216, cat: TV, desc: "!!! Полные завершенные сезоны !!!"} - - {id: 118, cat: TV, desc: "H2O - Просто добавь воды (3й сезон! Озвучка RiperAM!)"} - - {id: 153, cat: TV, desc: "Split. Тайна крови"} - - {id: 363, cat: TV, desc: "Адские кошки / Hellcats"} - - {id: 1055, cat: TV, desc: "Виолетта / Violetta"} - - {id: 632, cat: TV, desc: "Вспышка-любовь / Popland!"} - - {id: 183, cat: TV, desc: "Грань"} - - {id: 178, cat: TV, desc: "Гимнастки"} - - {id: 364, cat: TV, desc: "Дайте Санни шанс / Sonny With A Chance"} - - {id: 319, cat: TV, desc: "Два короля / Pair of Kings"} - - {id: 267, cat: TV, desc: "Держись, Чарли / Good Luck"} - - {id: 1166, cat: TV, desc: "Мушкетеры / The Musketeers"} - - {id: 365, cat: TV, desc: "Никита / Nikita"} - - {id: 353, cat: TV, desc: "Обитель Анубиса / House of Anubis"} - - {id: 710, cat: TV, desc: "Проспект Бразилии / Avenida Brasil"} - - {id: 266, cat: TV, desc: "Сверхъестественное / Supernatural"} - - {id: 268, cat: TV, desc: "Следствие по телу / Body of Proof"} - - {id: 160, cat: TV, desc: "Теория большого взрыва"} - - {id: 20, cat: TV, desc: "Русские сериалы"} - - {id: 210, cat: TV, desc: "!!! Полные завершенные сериалы !!!"} - - {id: 969, cat: TV, desc: "13"} - - {id: 707, cat: TV, desc: "Агент"} - - {id: 1028, cat: TV, desc: "Белые волки"} - - {id: 736, cat: TV, desc: "Бомбила. Продолжение"} - - {id: 550, cat: TV, desc: "Брат за брата"} - - {id: 601, cat: TV, desc: "Братаны"} - - {id: 979, cat: TV, desc: "В Москве всегда солнечно"} - - {id: 790, cat: TV, desc: "Горюнов"} - - {id: 791, cat: TV, desc: "Груз (Фельдъегеря)"} - - {id: 803, cat: TV, desc: "Двойная жизнь"} - - {id: 994, cat: TV, desc: "Дворняжка Ляля / Красотка Ляля / Ляля. Возвращение"} - - {id: 735, cat: TV, desc: "Дело врачей"} - - {id: 992, cat: TV, desc: "Дело для двоих"} - - {id: 771, cat: TV, desc: "Департамент"} - - {id: 964, cat: TV, desc: "Дом с лилиями"} - - {id: 746, cat: TV, desc: "Домработница"} - - {id: 976, cat: TV, desc: "До свидания, мальчики"} - - {id: 719, cat: TV, desc: "ЗАГС"} - - {id: 1006, cat: TV, desc: "Женщины на грани"} - - {id: 768, cat: TV, desc: "Земский доктор - Возвращение"} - - {id: 984, cat: TV, desc: "Земский доктор. Любовь вопреки"} - - {id: 732, cat: TV, desc: "Икорный барон"} - - {id: 983, cat: TV, desc: "Королева игры"} - - {id: 774, cat: TV, desc: "Кулинар-2"} - - {id: 989, cat: TV, desc: "Курортная полиция"} - - {id: 961, cat: TV, desc: "Личное дело"} - - {id: 639, cat: TV, desc: "Ловушка"} - - {id: 1013, cat: TV, desc: "Лучшие враги"} - - {id: 131, cat: TV, desc: "Майор полиции"} - - {id: 727, cat: TV, desc: "Маяковский - Два дня"} - - {id: 1058, cat: TV, desc: "Между двух огней"} - - {id: 641, cat: TV, desc: "Мент в законе-7"} - - {id: 644, cat: TV, desc: "Метод Лавровой-2"} - - {id: 779, cat: TV, desc: "Неzлоб"} - - {id: 372, cat: TV, desc: "Неравный брак"} - - {id: 957, cat: TV, desc: "Неформат"} - - {id: 766, cat: TV, desc: "ОСА"} - - {id: 991, cat: TV, desc: "Отмена всех ограничений"} - - {id: 653, cat: TV, desc: "Патруль. Васильевский остров"} - - {id: 962, cat: TV, desc: "Под прицелом"} - - {id: 683, cat: TV, desc: "Поцелуй"} - - {id: 708, cat: TV, desc: "Поцелуйте невесту"} - - {id: 1026, cat: TV, desc: "Профессионал"} - - {id: 787, cat: TV, desc: "Пропавшие без вести"} - - {id: 773, cat: TV, desc: "Пыльная работа"} - - {id: 799, cat: TV, desc: "Ржавчина"} - - {id: 794, cat: TV, desc: "Сашка"} - - {id: 756, cat: TV, desc: "Семейные обстоятельства"} - - {id: 706, cat: TV, desc: "Семин. Возмездие"} - - {id: 966, cat: TV, desc: "Скорая помощь"} - - {id: 960, cat: TV, desc: "Следователь Протасов"} - - {id: 714, cat: TV, desc: "Стервы"} - - {id: 755, cat: TV, desc: "Студия 17"} - - {id: 1039, cat: TV, desc: "Тайны следствия"} - - {id: 360, cat: TV, desc: "Такси"} - - {id: 981, cat: TV, desc: "Темный мир: Равновесие"} - - {id: 1046, cat: TV, desc: "Тест на беременность"} - - {id: 977, cat: TV, desc: "Тихая охота"} - - {id: 681, cat: TV, desc: "Топтуны"} - - {id: 675, cat: TV, desc: "Торговый центр"} - - {id: 988, cat: TV, desc: "Три звезды"} - - {id: 1033, cat: TV, desc: "Чужой"} - - {id: 690, cat: TV, desc: "Чужой район"} - - {id: 1030, cat: TV, desc: "Чужой среди своих"} - - {id: 804, cat: TV, desc: "Шаман"} - - {id: 780, cat: TV, desc: "Шерлок Холмс"} - - {id: 805, cat: TV, desc: "Этаж"} - - {id: 743, cat: TV, desc: "Ясмин"} - - {id: 973, cat: TV, desc: "Умник"} - - {id: 733, cat: TV, desc: "Хмуров"} - - {id: 505, cat: TV, desc: "Хозяйка моей судьбы"} - - {id: 1004, cat: TV, desc: "Анжелика"} - - {id: 1047, cat: TV, desc: "Бессмертник"} - - {id: 1074, cat: TV, desc: "Без свидетелей"} - - {id: 692, cat: TV, desc: "Верное средство"} - - {id: 1038, cat: TV, desc: "Верни мою любовь"} - - {id: 711, cat: TV, desc: "Береговая охрана"} - - {id: 1052, cat: TV, desc: "Граница времени"} - - {id: 1061, cat: TV, desc: "Второй шанс"} - - {id: 1042, cat: TV, desc: "Гречанка"} - - {id: 1108, cat: TV, desc: "Высокие ставки"} - - {id: 748, cat: TV, desc: "Дельта"} - - {id: 501, cat: TV, desc: "Дикий - 4"} - - {id: 980, cat: TV, desc: "Дознаватель"} - - {id: 1002, cat: TV, desc: "Долгий путь домой"} - - {id: 958, cat: TV, desc: "Дружба народов"} - - {id: 798, cat: TV, desc: "Зайцев+1"} - - {id: 1102, cat: TV, desc: "Запретная любовь"} - - {id: 1083, cat: TV, desc: "Код Константина"} - - {id: 1092, cat: TV, desc: "Измены"} - - {id: 565, cat: TV, desc: "Кодекс чести"} - - {id: 797, cat: TV, desc: "Корабль"} - - {id: 1070, cat: TV, desc: "Инспектор Купер"} - - {id: 640, cat: TV, desc: "Легавый"} - - {id: 1048, cat: TV, desc: "Луна"} - - {id: 1060, cat: TV, desc: "Ленинград 46"} - - {id: 684, cat: TV, desc: "Лесник"} - - {id: 1057, cat: TV, desc: "Между нами, девочками"} - - {id: 654, cat: TV, desc: "Морские дьяволы. Смерч"} - - {id: 1089, cat: TV, desc: "Неподкупный"} - - {id: 1095, cat: TV, desc: "Окрылённые"} - - {id: 1016, cat: TV, desc: "Отец Матвей"} - - {id: 687, cat: TV, desc: "Особый случай"} - - {id: 1054, cat: TV, desc: "Папа на вырост"} - - {id: 1094, cat: TV, desc: "Непридуманная жизнь"} - - {id: 986, cat: TV, desc: "Пляж"} - - {id: 750, cat: TV, desc: "Последний из Магикян"} - - {id: 1043, cat: TV, desc: "Последний янычар"} - - {id: 997, cat: TV, desc: "Практика"} - - {id: 1031, cat: TV, desc: "Хроники ломбарда"} - - {id: 1063, cat: TV, desc: "Цветок папоротника"} - - {id: 990, cat: TV, desc: "Человек - приманка"} - - {id: 1044, cat: TV, desc: "Свет и тень маяка"} - - {id: 1082, cat: TV, desc: "Следователи"} - - {id: 995, cat: TV, desc: "Слепая"} - - {id: 699, cat: TV, desc: "Солдаты 17 (Солдаты. Снова в строю)"} - - {id: 1025, cat: TV, desc: "Улыбка пересмешника"} - - {id: 1024, cat: TV, desc: "Литейный"} - - {id: 1023, cat: TV, desc: "Раскаяние"} - - {id: 1076, cat: TV, desc: "Родители"} - - {id: 972, cat: TV, desc: "Сватьи"} - - {id: 1073, cat: TV, desc: "Чужое гнездо"} - - {id: 1078, cat: TV, desc: "Чтец"} - - {id: 628, cat: TV, desc: "Карпов"} - - {id: 691, cat: TV, desc: "Черные кошки"} - - {id: 1101, cat: TV, desc: "Квест"} - - {id: 1088, cat: TV, desc: "Лондонград"} - - {id: 1072, cat: TV, desc: "Меч"} - - {id: 795, cat: TV, desc: "Пока станица спит"} - - {id: 978, cat: TV, desc: "ППС"} - - {id: 1003, cat: TV, desc: "Сердце звезды"} - - {id: 1194, cat: TV, desc: "Агентство скрытых камер"} - - {id: 1140, cat: TV, desc: "Академия"} - - {id: 1178, cat: TV, desc: "Беглые родственники"} - - {id: 1161, cat: TV, desc: "Бедные люди"} - - {id: 1099, cat: TV, desc: "Ботаны"} - - {id: 1136, cat: TV, desc: "Бородач"} - - {id: 1184, cat: TV, desc: "Вижу - знаю"} - - {id: 1127, cat: TV, desc: "Владимирская, 15"} - - {id: 1181, cat: TV, desc: "Вышибала"} - - {id: 1165, cat: TV, desc: "Гастролеры (Беглецы)"} - - {id: 1150, cat: TV, desc: "Гражданин Никто"} - - {id: 509, cat: TV, desc: "Два отца и два сына"} - - {id: 656, cat: TV, desc: "Деффчонки"} - - {id: 1128, cat: TV, desc: "Другой майор Соколов"} - - {id: 1192, cat: TV, desc: "Жемчуга"} - - {id: 1170, cat: TV, desc: "Законы улиц"} - - {id: 1183, cat: TV, desc: "Игра. Реванш"} - - {id: 1117, cat: TV, desc: "Иные"} - - {id: 1228, cat: TV, desc: "Казаки"} - - {id: 1107, cat: TV, desc: "Как я стал русским"} - - {id: 1077, cat: TV, desc: "Клан Ювелиров"} - - {id: 1118, cat: TV, desc: "Команда"} - - {id: 1132, cat: TV, desc: "Кости"} - - {id: 354, cat: TV, desc: "Кухня"} - - {id: 1040, cat: TV, desc: "Мажор"} - - {id: 1104, cat: TV, desc: "Метод"} - - {id: 1189, cat: TV, desc: "Москва. Центральный округ"} - - {id: 1135, cat: TV, desc: "На глубине"} - - {id: 1151, cat: TV, desc: "Напарницы"} - - {id: 1131, cat: TV, desc: "Не зарекайся"} - - {id: 1160, cat: TV, desc: "Невский"} - - {id: 1224, cat: TV, desc: "Нити судьбы"} - - {id: 1112, cat: TV, desc: "Озабоченные или любовь зла"} - - {id: 1080, cat: TV, desc: "Отдел 44"} - - {id: 761, cat: TV, desc: "Пасечник"} - - {id: 1103, cat: TV, desc: "Патруль Самооборона"} - - {id: 1157, cat: TV, desc: "Перевозчик"} - - {id: 1081, cat: TV, desc: "Пес"} - - {id: 1110, cat: TV, desc: "Последний мент"} - - {id: 1163, cat: TV, desc: "Последний москаль. Судный день"} - - {id: 1126, cat: TV, desc: "Прокуроры"} - - {id: 1179, cat: TV, desc: "Пушкин"} - - {id: 671, cat: TV, desc: "Пятая стража"} - - {id: 1019, cat: TV, desc: "Пятницкий. Глава четвертая"} - - {id: 1115, cat: TV, desc: "Ради любви я все смогу / Вечная любовь"} - - {id: 788, cat: TV, desc: "Розыск"} - - {id: 359, cat: TV, desc: "Светофор"} - - {id: 1098, cat: TV, desc: "Семейный бизнес"} - - {id: 1162, cat: TV, desc: "Следователь Тихонов"} - - {id: 1138, cat: TV, desc: "Солнце в подарок"} - - {id: 1180, cat: TV, desc: "Степные волки"} - - {id: 1133, cat: TV, desc: "Сын моего отца"} - - {id: 1049, cat: TV, desc: "Такая работа"} - - {id: 1185, cat: TV, desc: "Центральная больница"} - - {id: 1141, cat: TV, desc: "Человек без прошлого"} - - {id: 1223, cat: TV, desc: "Чёрная кошка"} - - {id: 1190, cat: TV, desc: "Шаман. Новая угроза"} - - {id: 1191, cat: TV, desc: "Я работаю в суде"} - - {id: 1235, cat: TV, desc: "Василиса"} - - {id: 1154, cat: TV, desc: "Вечный отпуск"} - - {id: 1231, cat: TV, desc: "Вы все меня бесите"} - - {id: 152, cat: TV, desc: "Интерны"} - - {id: 1156, cat: TV, desc: "Крыша мира"} - - {id: 1233, cat: TV, desc: "Райское место"} - - {id: 1302, cat: TV, desc: "Фамильные ценности"} - - {id: 1059, cat: TV, desc: "Это любовь"} - - {id: 1280, cat: TV, desc: "Адаптация"} - - {id: 48, cat: TV, desc: "Воронины"} - - {id: 1186, cat: TV, desc: "Кризис нежного возраста"} - - {id: 1281, cat: TV, desc: "Куба"} - - {id: 1278, cat: TV, desc: "Майор и магия"} - - {id: 1125, cat: TV, desc: "Мамочки"} - - {id: 1155, cat: TV, desc: "Мент в законе"} - - {id: 1015, cat: TV, desc: "Ментовские войны"} - - {id: 1276, cat: TV, desc: "Один против всех"} - - {id: 1193, cat: TV, desc: "Ольга"} - - {id: 1227, cat: TV, desc: "Отель Элеон"} - - {id: 551, cat: TV, desc: "Паутина"} - - {id: 1159, cat: TV, desc: "Подкидыши"} - - {id: 225, cat: TV, desc: "Реальные пацаны"} - - {id: 680, cat: TV, desc: "СашаТаня"} - - {id: 1301, cat: TV, desc: "Свидетели"} - - {id: 630, cat: TV, desc: "Склифосовский (Склиф)"} - - {id: 226, cat: TV, desc: "След"} - - {id: 1232, cat: TV, desc: "Стройка"} - - {id: 1045, cat: TV, desc: "Улицы разбитых фонарей"} - - {id: 445, cat: TV, desc: "Универ. Новая общага."} - - {id: 1285, cat: TV, desc: "Учитель в законе. Схватка"} - - {id: 970, cat: TV, desc: "Физрук"} - - {id: 1299, cat: TV, desc: "Филфак"} - - {id: 1062, cat: TV, desc: "ЧОП"} - - {id: 778, cat: TV, desc: "Шеф. Игра на повышение / Шеф-4"} - - {id: 21, cat: TV, desc: "Зарубежные мультсериалы"} - - {id: 185, cat: TV, desc: "WINX CLUB - Школа волшебниц"} - - {id: 463, cat: TV, desc: "Волшебные ПопПикси / PopPixie"} - - {id: 633, cat: TV, desc: "Winx Club 5й сезон - За гранью Беливикса"} - - {id: 772, cat: TV, desc: "Winx Club 6й сезон - Загадка Легендариума"} - - {id: 1093, cat: TV, desc: "Winx Club 7й сезон - Высшая сила животных"} - - {id: 606, cat: TV, desc: "Друзья ангелов"} - - {id: 1129, cat: TV, desc: "ЛедиБаг и Супер кот / Miraculous Ladybug"} - - {id: 789, cat: TV, desc: "Тотали Спайс! / Totally Spies!"} - - {id: 162, cat: TV, desc: "Футурама"} - - {id: 151, cat: TV, desc: "Хантик - Искатели секретов (Озвучка РиперАМ!)"} - - {id: 22, cat: TV, desc: "Русские мультсериалы"} - - {id: 1401, cat: TV, desc: "Маша и Медведь"} - - {id: 198, cat: TV, desc: "Архив Сериалов, Мультсериалов"} - - {id: 232, cat: TV/Anime, desc: "Аниме"} - - {id: 245, cat: TV/Anime, desc: "Полнометражные Аниме"} - - {id: 246, cat: TV/Anime, desc: "Аниме Сериалы"} - - {id: 592, cat: TV/Anime, desc: "High School DxD / Демоны старшей школы"} - - {id: 594, cat: TV/Anime, desc: "Корона Грешника / Guilty Crown"} - - {id: 591, cat: TV/Anime, desc: "Zero no Tsukaima TV-1 / Подручный бездарной Луизы ТВ-1"} - - {id: 595, cat: TV/Anime, desc: "Zero no Tsukaima: Futatsuki no Kishi TV-2 / Подручный бездарной Луизы: Рыцарь Двух Лун ТВ-2"} - - {id: 596, cat: TV/Anime, desc: "Zero no Tsukaima Princess no Rondo TV-3 / Подручный бездарной Луизы: Рондо Принцессы ТВ-3"} - - {id: 597, cat: TV/Anime, desc: "Zero no Tsukaima F TV-4 / Подручный бездарной Луизы Финал ТВ-4"} - - {id: 242, cat: TV, desc: "Без перевода"} - - {id: 241, cat: TV, desc: "ТВ-Шоу, документальные, спорт"} - - {id: 23, cat: TV, desc: "ТВ-Шоу и развлекательные передачи"} - - {id: 1075, cat: TV, desc: "Архив ТВ-Шоу"} - - {id: 491, cat: TV, desc: "Yesterday Live"} - - {id: 516, cat: TV, desc: "X-Фактор"} - - {id: 478, cat: TV, desc: "Без башни"} - - {id: 540, cat: TV, desc: "Болеро"} - - {id: 1010, cat: TV, desc: "Большие гонки"} - - {id: 538, cat: TV, desc: "Бункер News"} - - {id: 561, cat: TV, desc: "В черной-черной комнате"} - - {id: 713, cat: TV, desc: "Вышка"} - - {id: 191, cat: TV, desc: "Голые и смешные"} - - {id: 527, cat: TV, desc: "Готовим с Алексеем Зиминым"} - - {id: 460, cat: TV, desc: "Грязные деньги"} - - {id: 659, cat: TV, desc: "Два с половиной повара"} - - {id: 255, cat: TV, desc: "Девчата"} - - {id: 634, cat: TV, desc: "Дешево и сердито"} - - {id: 574, cat: TV, desc: "Дневники шоугелз"} - - {id: 764, cat: TV, desc: "ДНК"} - - {id: 637, cat: TV, desc: "Доброго здоровьица"} - - {id: 658, cat: TV, desc: "Дурнушек.net"} - - {id: 209, cat: TV, desc: "Ешь и худей"} - - {id: 800, cat: TV, desc: "Женское счастье"} - - {id: 475, cat: TV, desc: "Женская Лига"} - - {id: 740, cat: TV, desc: "Звезданутые"} - - {id: 741, cat: TV, desc: "Здравствуйте, я ваша Пятница"} - - {id: 737, cat: TV, desc: "Из песни слов не выкинешь"} - - {id: 587, cat: TV, desc: "Индустрия кино"} - - {id: 718, cat: TV, desc: "Истина где-то рядом"} - - {id: 1011, cat: TV, desc: "История Российской кухни"} - - {id: 456, cat: TV, desc: "Каникулы в Мексике"} - - {id: 682, cat: TV, desc: "Караоке киллер"} - - {id: 996, cat: TV, desc: "Когда мы дома"} - - {id: 785, cat: TV, desc: "Куб - Новый сезон"} - - {id: 668, cat: TV, desc: "Кулинарный поединок"} - - {id: 752, cat: TV, desc: "Луч Света"} - - {id: 533, cat: TV, desc: "Люди ХЭ"} - - {id: 447, cat: TV, desc: "Мама в законе"} - - {id: 529, cat: TV, desc: "Минута для победы с М. Авериным"} - - {id: 793, cat: TV, desc: "Мировой повар"} - - {id: 586, cat: TV, desc: "Минута славы"} - - {id: 472, cat: TV, desc: "Мульт Личности"} - - {id: 1009, cat: TV, desc: "Наш выход"} - - {id: 511, cat: TV, desc: "Нонна, давай"} - - {id: 531, cat: TV, desc: "НТВшники"} - - {id: 754, cat: TV, desc: "Остров"} - - {id: 715, cat: TV, desc: "Пародайс"} - - {id: 559, cat: TV, desc: "Первый класс"} - - {id: 636, cat: TV, desc: "Погоня"} - - {id: 679, cat: TV, desc: "Пока еще не поздно"} - - {id: 545, cat: TV, desc: "Понять. Простить"} - - {id: 537, cat: TV, desc: "Последнее слово"} - - {id: 792, cat: TV, desc: "Правила моей кухни"} - - {id: 547, cat: TV, desc: "Право на защиту"} - - {id: 731, cat: TV, desc: "Про декор"} - - {id: 536, cat: TV, desc: "Самый умный"} - - {id: 742, cat: TV, desc: "Свидание со звездой"} - - {id: 544, cat: TV, desc: "Свобода и справедливость"} - - {id: 963, cat: TV, desc: "Свободное время"} - - {id: 575, cat: TV, desc: "Снимите это немедленно!"} - - {id: 489, cat: TV, desc: "Специальное задание"} - - {id: 495, cat: TV, desc: "Спокойной ночи, малыши!"} - - {id: 689, cat: TV, desc: "Страна в Shope"} - - {id: 492, cat: TV, desc: "СуперИнтуиция"} - - {id: 535, cat: TV, desc: "Съешьте это немедленно!"} - - {id: 1005, cat: TV, desc: "Тотальная распродажа"} - - {id: 521, cat: TV, desc: "Украина чудес"} - - {id: 698, cat: TV, desc: "Устами младенца"} - - {id: 451, cat: TV, desc: "Улетное видео по русски"} - - {id: 697, cat: TV, desc: "ХБ"} - - {id: 497, cat: TV, desc: "Ху из ху"} - - {id: 494, cat: TV, desc: "Школа злословия"} - - {id: 448, cat: TV, desc: "Что делать?"} - - {id: 366, cat: TV, desc: "6 кадров"} - - {id: 1032, cat: TV, desc: "Автошкола 2: Девчонки рулят"} - - {id: 471, cat: TV, desc: "Большая Разница"} - - {id: 744, cat: TV, desc: "В наше время"} - - {id: 1056, cat: TV, desc: "Все будет хорошо"} - - {id: 344, cat: TV, desc: "Галилео"} - - {id: 477, cat: TV, desc: "Даешь молодежь"} - - {id: 514, cat: TV, desc: "Ералаш"} - - {id: 446, cat: TV, desc: "+100500"} - - {id: 1164, cat: TV, desc: "Битва риелторов"} - - {id: 693, cat: TV, desc: "Comedy Баттл. Без границ"} - - {id: 1071, cat: TV, desc: "Взвешенные люди"} - - {id: 229, cat: TV, desc: "Comedy Баттл. Отбор, турнир"} - - {id: 1096, cat: TV, desc: "Время Г"} - - {id: 522, cat: TV, desc: "Модный приговор"} - - {id: 971, cat: TV, desc: "Не спать!"} - - {id: 1137, cat: TV, desc: "Новая звезда"} - - {id: 1142, cat: TV, desc: "Таблетка"} - - {id: 677, cat: TV, desc: "Холостяк"} - - {id: 1034, cat: TV, desc: "Чисто News"} - - {id: 763, cat: TV, desc: "Я худею"} - - {id: 1087, cat: TV, desc: "Дикие игры"} - - {id: 649, cat: TV, desc: "Тайны Чапман"} - - {id: 452, cat: TV, desc: "Хочу знать"} - - {id: 1121, cat: TV, desc: "Хроники московского быта"} - - {id: 647, cat: TV, desc: "Чистосердечное признание"} - - {id: 643, cat: TV, desc: "Кинематограф"} - - {id: 532, cat: TV, desc: "Кино в деталях"} - - {id: 614, cat: TV, desc: "Мистические истории"} - - {id: 801, cat: TV, desc: "Пища богов"} - - {id: 974, cat: TV, desc: "Политика"} - - {id: 670, cat: TV, desc: "Репортерские истории"} - - {id: 252, cat: TV, desc: "Среда обитания"} - - {id: 89, cat: TV, desc: "Игровое видео/Фильм о фильме"} - - {id: 65, cat: TV, desc: "Вторая мировая война"} - - {id: 190, cat: TV, desc: "Давай поженимся"} - - {id: 530, cat: TV, desc: "Жди меня"} - - {id: 1086, cat: TV, desc: "На самом деле"} - - {id: 526, cat: TV, desc: "Вечерний квартал"} - - {id: 605, cat: TV, desc: "Вечерний Ургант"} - - {id: 1124, cat: TV, desc: "Где логика?"} - - {id: 480, cat: TV, desc: "Камеди Woman"} - - {id: 459, cat: TV, desc: "Говорим и показываем"} - - {id: 479, cat: TV, desc: "Comedy Club - Камеди Клаб"} - - {id: 33, cat: TV, desc: "ДОМ 2"} - - {id: 270, cat: TV, desc: "Архив Дом-2"} - - {id: 1484, cat: TV, desc: "ДНК"} - - {id: 1483, cat: TV, desc: "За гранью"} - - {id: 1469, cat: TV, desc: "Звезды сошлись"} - - {id: 696, cat: TV, desc: "Квартирный вопрос"} - - {id: 1468, cat: TV, desc: "Квартирник НТВ у Маргулиса"} - - {id: 469, cat: TV, desc: "КВН - Клуб Веселых и Находчивых"} - - {id: 541, cat: TV, desc: "Кто хочет стать миллионером"} - - {id: 189, cat: TV, desc: "Контрольная закупка"} - - {id: 1284, cat: TV, desc: "Лига смеха"} - - {id: 775, cat: TV, desc: "МастерШеф"} - - {id: 1291, cat: TV, desc: "Международная пилорама"} - - {id: 1018, cat: TV, desc: "Мужское - женское"} - - {id: 769, cat: TV, desc: "Наедине со всеми"} - - {id: 1123, cat: TV, desc: "НашПотребНадзор"} - - {id: 1017, cat: TV, desc: "Однажды в России"} - - {id: 1309, cat: TV, desc: "Пацанки"} - - {id: 765, cat: TV, desc: "Перезагрузка"} - - {id: 493, cat: TV, desc: "Поле чудес"} - - {id: 730, cat: TV, desc: "Поедем, поедим"} - - {id: 1146, cat: TV, desc: "Когда все дома"} - - {id: 1196, cat: TV, desc: "Проводник"} - - {id: 588, cat: TV, desc: "Прямой эфир"} - - {id: 348, cat: TV, desc: "Пусть говорят"} - - {id: 1158, cat: TV, desc: "Ревизорро"} - - {id: 490, cat: TV, desc: "Сам себе режиссёр"} - - {id: 457, cat: TV, desc: "Своя игра"} - - {id: 520, cat: TV, desc: "Смак"} - - {id: 1106, cat: TV, desc: "Соль"} - - {id: 975, cat: TV, desc: "STAND UP"} - - {id: 496, cat: TV, desc: "Сто к одному"} - - {id: 786, cat: TV, desc: "Сегодня вечером"} - - {id: 967, cat: TV, desc: "Comedy Баттл. Последний сезон"} - - {id: 1287, cat: TV, desc: "Секрет на миллион"} - - {id: 1021, cat: TV, desc: "Танцы"} - - {id: 1286, cat: TV, desc: "Ты супер!"} - - {id: 470, cat: TV, desc: "Уральские пельмени"} - - {id: 1290, cat: TV, desc: "Устами младенца"} - - {id: 1147, cat: TV, desc: "Фазенда"} - - {id: 188, cat: TV, desc: "Центральное телевидение"} - - {id: 543, cat: TV, desc: "Что? Где? Когда?"} - - {id: 738, cat: TV, desc: "Школа ремонта"} - - {id: 59, cat: TV, desc: "Э К С Т Р А С Е Н С Ы"} - - {id: 281, cat: TV, desc: "Удиви меня"} - - {id: 1035, cat: TV, desc: "Черно-белое"} - - {id: 280, cat: TV, desc: "Экстрасенсы против ученых"} - - {id: 279, cat: TV, desc: "Битва экстрасенсов"} - - {id: 1461, cat: TV/Documentary, desc: "Невероятно интересные истории"} - - {id: 1477, cat: TV/Documentary, desc: "Последний герой"} - - {id: 1478, cat: TV/Documentary, desc: "Бородина против Бузовой"} - - {id: 1488, cat: TV/Documentary, desc: "Док-ток"} - - {id: 1493, cat: TV/Documentary, desc: "'Рожденные в СССР' Ностальгия"} - - {id: 24, cat: TV/Documentary, desc: "Документальные передачи"} - - {id: 1460, cat: TV/Documentary, desc: "Специальный репортаж (ТК Звезда)"} - - {id: 728, cat: TV/Documentary, desc: "Чудо техники с Сергеем Малозёмовым"} - - {id: 69, cat: TV/Documentary, desc: "Авто"} - - {id: 669, cat: TV/Documentary, desc: "Главная дорога"} - - {id: 704, cat: TV/Documentary, desc: "Первая передача"} - - {id: 1111, cat: TV/Documentary, desc: "Без обмана"} - - {id: 88, cat: TV/Documentary, desc: "Биографии. Личности и кумиры"} - - {id: 68, cat: TV/Documentary, desc: "Военное дело"} - - {id: 729, cat: TV/Documentary, desc: "Дачный ответ"} - - {id: 1152, cat: TV/Documentary, desc: "Живая еда"} - - {id: 194, cat: TV/Documentary, desc: "Discovery / NatGeo / BBC / Viasat"} - - {id: 1307, cat: TV/Documentary, desc: "Загадки человечества"} - - {id: 701, cat: TV/Documentary, desc: "Идеальный ремонт"} - - {id: 63, cat: TV/Documentary, desc: "Криминал"} - - {id: 1481, cat: TV/Documentary, desc: "Закон и порядок"} - - {id: 796, cat: TV/Documentary, desc: "Место происшествия. О главном"} - - {id: 1470, cat: TV/Documentary, desc: "По следу монстра"} - - {id: 1085, cat: TV/Documentary, desc: "Следствие ведут..."} - - {id: 652, cat: TV/Documentary, desc: "Следствие вели..."} - - {id: 498, cat: TV/Documentary, desc: "Чрезвычайное происшествие"} - - {id: 1120, cat: TV/Documentary, desc: "Линия защиты"} - - {id: 1145, cat: TV/Documentary, desc: "Непутевые заметки с Дмитрием Крыловым"} - - {id: 1067, cat: TV/Documentary, desc: "Обложка"} - - {id: 1288, cat: TV/Documentary, desc: "Однажды"} - - {id: 1119, cat: TV/Documentary, desc: "Осторожно, мошенники!"} - - {id: 1149, cat: TV/Documentary, desc: "Открытие Китая"} - - {id: 739, cat: TV/Documentary, desc: "Очная ставка"} - - {id: 965, cat: TV/Documentary, desc: "Познер. Интервью с..."} - - {id: 650, cat: TV/Documentary, desc: "Русские сенсации"} - - {id: 1122, cat: TV/Documentary, desc: "Русский вопрос"} - - {id: 1198, cat: TV/Documentary, desc: "Самые шокирующие гипотезы"} - - {id: 802, cat: TV/Documentary, desc: "Территория заблуждений с Игорем Прокопенко"} - - {id: 1479, cat: TV/Documentary, desc: "Как устроен мир с Тимофеем Баженовым"} - - {id: 323, cat: TV/Documentary, desc: "Информационно-аналитические и общественно-политические передачи"} - - {id: 1195, cat: TV/Documentary, desc: "60 минут"} - - {id: 1041, cat: TV/Documentary, desc: "Вести"} - - {id: 672, cat: TV/Documentary, desc: "Вести в субботу с Сергеем Брилевым"} - - {id: 655, cat: TV/Documentary, desc: "Вести недели"} - - {id: 1007, cat: TV/Documentary, desc: "Время покажет"} - - {id: 673, cat: TV/Documentary, desc: "Воскресное время - Первый канал"} - - {id: 759, cat: TV/Documentary, desc: "В центре событий с Анной Прохоровой"} - - {id: 999, cat: TV/Documentary, desc: "Добров в эфире"} - - {id: 758, cat: TV/Documentary, desc: "Известия. Главное"} - - {id: 1230, cat: TV/Documentary, desc: "Итоги недели"} - - {id: 1400, cat: TV/Documentary, desc: "Итоги недели с Сергеем Михеевым"} - - {id: 1402, cat: TV/Documentary, desc: "Кто против?"} - - {id: 1148, cat: TV/Documentary, desc: "Место встречи"} - - {id: 648, cat: TV/Documentary, desc: "Момент истины"} - - {id: 1279, cat: TV/Documentary, desc: "Первая студия"} - - {id: 327, cat: TV/Documentary, desc: "Поединок с Владимиром Соловьевым"} - - {id: 324, cat: TV/Documentary, desc: "Постскриптум с Алексеем Пушковым"} - - {id: 705, cat: TV/Documentary, desc: "Право голоса"} - - {id: 1029, cat: TV/Documentary, desc: "Право знать!"} - - {id: 1153, cat: TV/Documentary, desc: "Процесс"} - - {id: 1405, cat: TV/Documentary, desc: "Своя правда"} - - {id: 337, cat: TV/Documentary, desc: "Ты не поверишь!"} - - {id: 330, cat: TV/Documentary, desc: "Человек и закон"} - - {id: 25, cat: TV/Sport, desc: "Спортивные передачи"} - - {id: 127, cat: TV/Sport, desc: "ХХII Зимние Олимпийские Игры в Сочи"} - - {id: 616, cat: TV/Sport, desc: "Фигурное катание"} - - {id: 201, cat: TV/Sport, desc: "Бокс, боевые единоборства , рестлинг"} - - {id: 146, cat: TV/Sport, desc: "Гонки"} - - {id: 145, cat: TV/Sport, desc: "Футбол"} - - {id: 985, cat: TV/Sport, desc: "Футбол. Чемпионат мира 2014 (Бразилия)"} - - {id: 661, cat: TV/Sport, desc: "Футбол.ru"} - - {id: 211, cat: TV/Sport, desc: "Хоккей"} - - {id: 192, cat: TV/Sport, desc: "ЧМ-2010"} - - {id: 622, cat: TV/Sport, desc: "XXIV Зимние Олимпийские игры 2022"} - - {id: 346, cat: TV/Sport, desc: "Театр"} - - {id: 199, cat: TV/Sport, desc: "Архив ТВ-Шоу, документальные, спорт"} - - {id: 577, cat: TV/Sport, desc: "Адская кухня - 2 (Россия)"} - - {id: 674, cat: TV/Sport, desc: "Большие танцы"} - - {id: 579, cat: TV/Sport, desc: "Две звезды"} - - {id: 576, cat: TV/Sport, desc: "Смотреть всем!"} - - {id: 694, cat: TV/Sport, desc: "Фитнес"} - - {id: 589, cat: TV/Sport, desc: "Достояние республики. Новый сезон 2011!"} - - {id: 585, cat: TV/Sport, desc: "Городок"} - - {id: 584, cat: TV/Sport, desc: "Адская кухня"} - - {id: 269, cat: TV/Sport, desc: "Одна за всех"} - - {id: 515, cat: TV/Sport, desc: "Стыдно, когда видно!"} - - {id: 468, cat: TV/Sport, desc: "Прожекторперисхилтон"} - - {id: 762, cat: TV/Sport, desc: "Хит"} - - {id: 767, cat: TV/Sport, desc: "Шурочка"} - - {id: 1113, cat: TV/Sport, desc: "Акценты недели"} - - {id: 333, cat: TV/Sport, desc: "Брачное чтиво"} - - {id: 1097, cat: TV/Sport, desc: "В теме"} - - {id: 1064, cat: TV/Sport, desc: "Время в 21:00"} - - {id: 332, cat: TV/Sport, desc: "ЖКХ"} - - {id: 660, cat: TV/Sport, desc: "Михаил Жванецкий. Дежурный по стране"} - - {id: 998, cat: TV/Sport, desc: "Итоги дня"} - - {id: 331, cat: TV/Sport, desc: "Неделя с Марианной Максимовской"} - - {id: 335, cat: TV/Sport, desc: "Операция «Должник»"} - - {id: 1134, cat: TV/Sport, desc: "Открытая студия"} - - {id: 1143, cat: TV/Sport, desc: "Поздняков. Интервью с ..."} - - {id: 336, cat: TV/Sport, desc: "Секретные территории"} - - {id: 702, cat: TV/Sport, desc: "Собчак живьём"} - - {id: 328, cat: TV/Sport, desc: "Специальный корреспондент"} - - {id: 1053, cat: TV/Sport, desc: "Список Норкина"} - - {id: 1022, cat: TV/Sport, desc: "Структура Момента"} - - {id: 325, cat: TV/Sport, desc: "Судите сами"} - - {id: 326, cat: TV/Sport, desc: "Честный понедельник"} - - {id: 329, cat: TV/Sport, desc: "Сегодня. Итоговая программа"} - - {id: 1100, cat: TV/Sport, desc: "Пропаганда"} - - {id: 1084, cat: TV/Sport, desc: "'Точка' с Максимом Шевченко"} - - {id: 334, cat: TV/Sport, desc: "День космических историй"} - - {id: 1091, cat: TV/Sport, desc: "50 оттенков. Белова"} - - {id: 298, cat: Console, desc: "Игры"} - - {id: 1202, cat: Console, desc: "Горячие RePack'и"} - - {id: 302, cat: Console, desc: "Горячие Новинки"} - - {id: 1225, cat: Console, desc: "Denuvo"} - - {id: 300, cat: Console, desc: "Игры для PC"} - - {id: 312, cat: Console, desc: "Action"} - - {id: 807, cat: Console, desc: "Action FPS (1st Person)"} - - {id: 808, cat: Console, desc: "Action TPS (3rd Person)"} - - {id: 665, cat: Console, desc: "Антологии"} - - {id: 309, cat: Console, desc: "Стратегии"} - - {id: 810, cat: Console, desc: "RTS (стратегии в реальном времени)"} - - {id: 809, cat: Console, desc: "TBS (пошаговые стратегии)"} - - {id: 811, cat: Console, desc: "Экономические стратегии"} - - {id: 938, cat: Console, desc: "Военные стратегии"} - - {id: 338, cat: Console, desc: "RPG"} - - {id: 1203, cat: Console, desc: "MMORPG"} - - {id: 305, cat: Console, desc: "Аркады"} - - {id: 717, cat: Console, desc: "Симуляторы"} - - {id: 1204, cat: Console, desc: "Авиасимуляторы"} - - {id: 1205, cat: Console, desc: "Гоночные симуляторы"} - - {id: 1206, cat: Console, desc: "Спортивные симуляторы"} - - {id: 311, cat: Console, desc: "Приключения, Квесты, Логические"} - - {id: 1207, cat: Console, desc: "Старые игры"} - - {id: 307, cat: Console, desc: "Детские обучающие игры"} - - {id: 812, cat: Console, desc: "Онлайновые игры"} - - {id: 1215, cat: Console, desc: "Эротические игры"} - - {id: 303, cat: Console, desc: "Мини-игры"} - - {id: 1208, cat: Console, desc: "Demo, Alpha, Beta версии"} - - {id: 313, cat: Console, desc: "Прочее"} - - {id: 864, cat: Console, desc: "Дополнения к играм"} - - {id: 1209, cat: Console, desc: "NoCD/NoDVD"} - - {id: 1210, cat: Console, desc: "Русификаторы"} - - {id: 1211, cat: Console, desc: "Игровое видео"} - - {id: 1212, cat: Console, desc: "Патчи"} - - {id: 1213, cat: Console, desc: "Софт для игр на PC"} - - {id: 1214, cat: Console, desc: "Прочее"} - - {id: 1415, cat: Console, desc: "VR"} - - {id: 1489, cat: Console, desc: "S.T.A.L.K.E.R"} - - {id: 382, cat: PC, desc: "Программное обеспечение"} - - {id: 1237, cat: PC, desc: "Операционные системы"} - - {id: 1486, cat: PC, desc: "Windows 11"} - - {id: 1238, cat: PC, desc: "Windows 10"} - - {id: 1239, cat: PC, desc: "Windows 8, 8.1"} - - {id: 1240, cat: PC, desc: "Windows 7"} - - {id: 1243, cat: PC, desc: "Windows VISTA"} - - {id: 1241, cat: PC, desc: "Windows XP"} - - {id: 1275, cat: PC, desc: "Linux, Unix, Mac и другие ОС"} - - {id: 1242, cat: PC, desc: "Разное (Microsoft Windows OS)"} - - {id: 391, cat: PC, desc: "Системные программы"} - - {id: 405, cat: PC, desc: "Драйвера и сопутствующий софт"} - - {id: 1254, cat: PC, desc: "Архиваторы и файловые менеджеры"} - - {id: 1255, cat: PC, desc: "Программы для настройки и оптимизации ОС"} - - {id: 724, cat: PC, desc: "Защита, Антивирусы и Файерволы"} - - {id: 406, cat: PC, desc: "Сборники программ, WPI"} - - {id: 407, cat: PC, desc: "Работа с жёстким диском"} - - {id: 1245, cat: PC, desc: "Резервирование и восстановление данных"} - - {id: 1247, cat: PC, desc: "Работа с носителями информации"} - - {id: 1248, cat: PC, desc: "Обслуживание и диагностика компьютера"} - - {id: 1249, cat: PC, desc: "ПО для Интернета и сетей"} - - {id: 1251, cat: PC, desc: "Изменение интерфейса и скринсейверы ОС Windows"} - - {id: 408, cat: PC, desc: "Разное (Системные программы)"} - - {id: 399, cat: PC, desc: "Программы для работы с мультимедиа и 3D"} - - {id: 400, cat: PC, desc: "Графические редакторы, 3D моделирование"} - - {id: 401, cat: PC, desc: "Редакторы видео"} - - {id: 402, cat: PC, desc: "Видео- Аудио- конверторы"} - - {id: 404, cat: PC, desc: "Видео- Аудио- проигрыватели"} - - {id: 1270, cat: PC, desc: "Работа со звуком"} - - {id: 1271, cat: PC, desc: "Аудио- Видео Кодеки"} - - {id: 403, cat: PC, desc: "Разное (Программы для работы с мультимедиа и 3D)"} - - {id: 1244, cat: PC, desc: "Системы для бизнеса, офиса, научной и проектной работы"} - - {id: 1261, cat: PC, desc: "ГИС, Системы навигации и карты"} - - {id: 1256, cat: PC, desc: "Системы для научной работы"} - - {id: 1257, cat: PC, desc: "Офисные системы"} - - {id: 1263, cat: PC, desc: "Веб-разработка и Программирование"} - - {id: 1258, cat: PC, desc: "Системы для бизнеса"} - - {id: 1259, cat: PC, desc: "Работа с PDF и DjVu"} - - {id: 1262, cat: PC, desc: "Словари, переводчики"} - - {id: 1260, cat: PC, desc: "САПР, Софт для архитекторов и строителей"} - - {id: 1393, cat: PC, desc: "Разное"} - - {id: 726, cat: Other, desc: "Разное"} - - {id: 1273, cat: Other, desc: "Обои для рабочего стола"} - - {id: 1390, cat: Other, desc: "Аватары, Иконки, Смайлы"} - - {id: 1391, cat: Other, desc: "Растровые и Векторные Клипарты"} - - {id: 1272, cat: Other, desc: "Photoshop (Уроки) и Видеокурсы"} - - {id: 1392, cat: Other, desc: "Заготовки, виньетки, рамки"} - - {id: 1408, cat: PC/Mobile-Other, desc: "Мобильные устройства"} - - {id: 1409, cat: PC/Mobile-Android, desc: "Android"} - - {id: 1411, cat: PC/Mobile-Android, desc: "18+ -Android"} - - {id: 1418, cat: PC/Mobile-Android, desc: "TV|Видео|IPTV - Android"} - - {id: 1419, cat: PC/Mobile-Android, desc: "Утилиты|Система - Android"} - - {id: 1420, cat: PC/Mobile-Android, desc: "Видеоплееры|Аудиоплееры - Android"} - - {id: 1421, cat: PC/Mobile-Android, desc: "VPN|Интернет - Android"} - - {id: 1422, cat: PC/Mobile-Android, desc: "Соцсети|Мессенджеры - Android"} - - {id: 1423, cat: PC/Mobile-Android, desc: "Авто|Карты - Android"} - - {id: 1424, cat: PC/Mobile-Android, desc: "Редакторы|Фоторедакторы - Android"} - - {id: 1480, cat: PC/Mobile-Android, desc: "Игры - Android"} - - {id: 243, cat: Audio, desc: "Музыка"} - - {id: 26, cat: Audio, desc: "Правила раздела 'Музыка'"} - - {id: 96, cat: Audio, desc: "HD Видео"} - - {id: 70, cat: Audio, desc: "Pop"} - - {id: 97, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 72, cat: Audio, desc: "Retro/Bandstand"} - - {id: 1358, cat: Audio/Lossless, desc: "Retro/Bandstand - lossless"} - - {id: 1359, cat: Audio/MP3, desc: "Retro/Bandstand - MP3"} - - {id: 71, cat: Audio, desc: "Disco"} - - {id: 1356, cat: Audio/Lossless, desc: "Disco - lossless"} - - {id: 1357, cat: Audio/MP3, desc: "Disco - MP3"} - - {id: 1354, cat: Audio/Lossless, desc: "Pop - lossless"} - - {id: 1355, cat: Audio/MP3, desc: "Pop - MP3"} - - {id: 73, cat: Audio, desc: "Rock"} - - {id: 98, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 1360, cat: Audio/Lossless, desc: "Rock - lossless"} - - {id: 1361, cat: Audio/MP3, desc: "Rock - MP3"} - - {id: 74, cat: Audio, desc: "Alternative"} - - {id: 99, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 1362, cat: Audio/Lossless, desc: "Alternative - lossless"} - - {id: 1363, cat: Audio/MP3, desc: "Alternative - MP3"} - - {id: 75, cat: Audio, desc: "Metal"} - - {id: 100, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 1364, cat: Audio/MP3, desc: "Metal - MP3"} - - {id: 1365, cat: Audio/Lossless, desc: "Metal - lossless"} - - {id: 76, cat: Audio, desc: "Rap, Hip-Hop, R'n'B"} - - {id: 101, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 78, cat: Audio, desc: "Зарубежный"} - - {id: 1368, cat: Audio/Lossless, desc: "Зарубежный - lossless"} - - {id: 1369, cat: Audio/MP3, desc: "Зарубежный - MP3"} - - {id: 77, cat: Audio, desc: "Отечественный"} - - {id: 1370, cat: Audio/Lossless, desc: "Отечественный - lossless"} - - {id: 1371, cat: Audio/MP3, desc: "Отечественный - MP3"} - - {id: 1366, cat: Audio/Lossless, desc: "Rap, Hip-Hop, R'n'B - lossless"} - - {id: 1367, cat: Audio/MP3, desc: "Rap, Hip-Hop, R'n'B - MP3"} - - {id: 85, cat: Audio, desc: "Jazz, Blues"} - - {id: 1380, cat: Audio/Lossless, desc: "Jazz, Blues - lossless"} - - {id: 1381, cat: Audio/MP3, desc: "Jazz, Blues - MP3"} - - {id: 82, cat: Audio, desc: "Chanson, Bard"} - - {id: 1386, cat: Audio/Lossless, desc: "Chanson, Bard - lossless"} - - {id: 1387, cat: Audio/MP3, desc: "Chanson, Bard - MP3"} - - {id: 86, cat: Audio, desc: "Classical"} - - {id: 1378, cat: Audio/Lossless, desc: "Classical - lossless"} - - {id: 1379, cat: Audio/MP3, desc: "Classical - MP3"} - - {id: 79, cat: Audio, desc: "Electronic"} - - {id: 102, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 1372, cat: Audio, desc: "Electronic - lossless"} - - {id: 1373, cat: Audio/MP3, desc: "Electronic - MP3"} - - {id: 80, cat: Audio, desc: "Other genres"} - - {id: 103, cat: Audio, desc: "Видеоклипы / Концерты"} - - {id: 87, cat: Audio, desc: "Военная песня"} - - {id: 1376, cat: Audio/Lossless, desc: "Военная песня - lossless"} - - {id: 1377, cat: Audio/MP3, desc: "Военная песня - MP3"} - - {id: 84, cat: Audio, desc: "Reggae, Ska, Dub"} - - {id: 1382, cat: Audio/Lossless, desc: "Reggae, Ska, Dub - lossless"} - - {id: 1383, cat: Audio/MP3, desc: "Reggae, Ska, Dub - MP3"} - - {id: 83, cat: Audio, desc: "Funk, Soul, Country"} - - {id: 1384, cat: Audio/Lossless, desc: "Funk, Soul, Country - lossless"} - - {id: 1385, cat: Audio/MP3, desc: "Funk, Soul, Country - MP3"} - - {id: 81, cat: Audio, desc: "Folk, Ethnic, New Age, Meditative, Punk"} - - {id: 1388, cat: Audio/Lossless, desc: "Folk, Ethnic, New Age, Meditative, Punk - lossless"} - - {id: 1389, cat: Audio/MP3, desc: "Folk, Ethnic, New Age, Meditative, Punk - MP3"} - - {id: 1374, cat: Audio/Lossless, desc: "Other genres - lossless"} - - {id: 1375, cat: Audio/MP3, desc: "Other genres - MP3"} - - {id: 663, cat: Books, desc: "Книги (Аудиокниги) и журналы"} - - {id: 1036, cat: Books, desc: "Книги"} - - {id: 1324, cat: Books, desc: "Новинки"} - - {id: 1331, cat: Books, desc: "Детские книги"} - - {id: 1325, cat: Books, desc: "Боевики/Детективы/Триллеры"} - - {id: 1334, cat: Books, desc: "Военная литература"} - - {id: 1326, cat: Books, desc: "Приключения"} - - {id: 1332, cat: Books, desc: "Комедийные произведения, юмор, сатира"} - - {id: 1328, cat: Books, desc: "Отечественная фантастика / фэнтези / мистика / ужасы"} - - {id: 1329, cat: Books, desc: "Зарубежная фантастика / фэнтези / мистика / ужасы"} - - {id: 1330, cat: Books, desc: "Классическая литература и современная проза"} - - {id: 1462, cat: Books, desc: "Научно-популярная литература"} - - {id: 1471, cat: Books, desc: "Кулинария"} - - {id: 1333, cat: Books, desc: "Разное"} - - {id: 1485, cat: Books, desc: "Медицина"} - - {id: 1494, cat: Books, desc: "Манга / Комиксы"} - - {id: 1312, cat: Books, desc: "Аудиокниги"} - - {id: 1398, cat: Books, desc: "Приключения"} - - {id: 1396, cat: Books, desc: "Детектив"} - - {id: 1397, cat: Books, desc: "Боевик / Триллер"} - - {id: 1313, cat: Books, desc: "История"} - - {id: 1315, cat: Books, desc: "Юмор"} - - {id: 1316, cat: Books, desc: "Религия"} - - {id: 1399, cat: Books, desc: "Ужасы / Мистика"} - - {id: 1318, cat: Books, desc: "Фантастика / фэнтези"} - - {id: 1319, cat: Books, desc: "Классика"} - - {id: 1320, cat: Books, desc: "Поэзия"} - - {id: 1321, cat: Books, desc: "Психология"} - - {id: 1317, cat: Books, desc: "Детская литература"} - - {id: 1322, cat: Books, desc: "Художественная литература"} - - {id: 1474, cat: Books, desc: "Нон-фикшн"} - - {id: 1323, cat: Books, desc: "Прочая литература"} - - {id: 1037, cat: Books, desc: "Журналы"} - - {id: 1335, cat: Books, desc: "Авто-мото журналы"} - - {id: 1336, cat: Books, desc: "Строительство и дизайн"} - - {id: 1337, cat: Books, desc: "Политика и бизнес"} - - {id: 1338, cat: Books, desc: "Женские журналы"} - - {id: 1339, cat: Books, desc: "Медицина и ЗОЖ"} - - {id: 1340, cat: Books, desc: "Аудио-видео журналы"} - - {id: 1341, cat: Books, desc: "Компьютерные журналы"} - - {id: 1342, cat: Books, desc: "Кулинария"} - - {id: 1343, cat: Books, desc: "Сад, огород, дача"} - - {id: 1344, cat: Books, desc: "Мужские журналы"} - - {id: 1345, cat: Books, desc: "Научно-популярные журналы"} - - {id: 1346, cat: Books, desc: "Домоводство / Своими руками"} - - {id: 1347, cat: Books, desc: "Эротические журналы"} - - {id: 1348, cat: Books, desc: "Комиксы"} - - {id: 1349, cat: Books, desc: "Детские журналы"} - - {id: 1351, cat: Books, desc: "Военные журналы"} - - {id: 1350, cat: Books, desc: "Хобби (Разное)"} - - {id: 1292, cat: Other, desc: "Шаблоны оформления раздач"} - - {id: 1310, cat: Other, desc: "Шаблон RePack от xatab"} - - {id: 1298, cat: Other, desc: "Шаблон оформления VickNet"} - - {id: 1300, cat: Other, desc: "Шаблон стандартного оформления"} - - {id: 1353, cat: Other, desc: "Стандартное оформление"} - - {id: 1406, cat: Other, desc: "Шаблон RG Games"} - - {id: 1707, cat: Other, desc: "Шаблоны BBCode таблиц"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: t - options: - t: created - ts: seeders - sz: size - i: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: ucp.php?mode=login - method: form - form: form#login - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - autologin: on - error: - - selector: fieldset:has(div.error) - test: - path: / - selector: :has(a[href*="/ucp.php?mode=logout&sid="]) - -search: - paths: - # http://riperam.org/search.php?tracker_search=torrent&keywords=captain&terms=all&fp=1&author=&fid[]=1404&sc=1&sf=titleonly&sr=topics&sk=t&sd=d&st=0&ch=300&t=0 - - path: search.php - inputs: - $raw: "{{ range .Categories }}&fid[]={{.}}{{end}}" - tracker_search: torrent - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - terms: all - fp: 0 - sc: 1 - sf: titleonly - sr: topics - sk: "{{ .Config.sort }}" - sd: "{{ .Config.type }}" - st: 0 - ch: 300 - t: 0 - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: li.row:has(a[href^="./download/file.php?id="]) - - fields: - category_optional: - selector: a[href*="/viewtopic.php?f="] - optional: true - filters: - - name: querystring - args: f - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}726{{ end }}" - title: - selector: a.topictitle - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?)\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?)\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ери[ияй]|Эпизод|Выпуски?)\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ери[ияй]|Эпизод|Выпуски?)", "E$1"] - - name: re_replace - args: ["(?i)\\bКураж-Бамбей\\b", "kurazh"] - - name: re_replace - args: ["(?i)\\bКубик в Кубе\\b", "Kubik"] - - name: re_replace - args: ["(?i)\\bКравец\\b", "Kravec"] - - name: re_replace - args: ["(?i)\\bПифагор\\b", "Pifagor"] - - name: re_replace - args: ["(?i)\\bНевафильм\\b", "Nevafilm"] - - name: re_replace - args: ["(?i)\\bЛицензия\\b", "Lic"] - - name: re_replace - args: ["(?i)\\bселезень\\b", "selezen"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a.topictitle - attribute: href - description: - selector: dt a:last-of-type - download: - selector: a[href^="./download/file.php?id="] - attribute: href - size: - selector: dt - filters: - # extract the size - - name: regexp - args: "Размер: (.+?)," - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: dd.posts span.seed - leechers: - selector: dd.posts span.leech - grabs: - selector: dd.views span.complet - optional: true - date: - # 12 янв 2019, 11:26 - selector: dt - # auto adjusted by site account profile - filters: - - name: regexp - args: "(\\d{2} \\D{3} \\d{4}, \\d{2}:\\d{2})" - - name: replace - args: ["янв", "Jan"] - - name: replace - args: ["фев", "Feb"] - - name: replace - args: ["мар", "Mar"] - - name: replace - args: ["апр", "Apr"] - - name: replace - args: ["май", "May"] - - name: replace - args: ["июн", "Jun"] - - name: replace - args: ["июл", "Jul"] - - name: replace - args: ["авг", "Aug"] - - name: replace - args: ["сен", "Sep"] - - name: replace - args: ["окт", "Oct"] - - name: replace - args: ["ноя", "Nov"] - - name: replace - args: ["дек", "Dec"] - - name: dateparse - args: "dd MMM yyyy, HH:mm" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rockbox.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rockbox.yml deleted file mode 100644 index 5c8e1d13..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rockbox.yml +++ /dev/null @@ -1,117 +0,0 @@ ---- -id: RockBox -name: RockBox -description: "RockBox Semi-Private Torrent Tracker dedicated to HEAVY METAL/ROCK MUSIC. This definition is for the English site." -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://rawkbawx.rocks/ - -caps: - categories: - Music: Audio - - modes: - search: [q] - music-search: [q, album, artist] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - seeds: seeders - size: size - filename: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: login.php - method: form - form: form[action="login.php?returnto=index.php"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: div > font[color="#FF0000"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - search: "{{ if or (.Query.Artist) (.Query.Album) }}{{ or (.Query.Artist) (.Query.Album) }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 all, 1 active, 2 dead, 3 seedwanted - active: 0 - # 0 title, 1 title+descr, 2 title+subgen - options: 0 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.lista[width="100%"] tbody tr:has(a[href^="download.php?id="]) - - fields: - category: - text: Music - title: - selector: td a[href^="details.php?id="] - details: - selector: td a[href^="details.php?id="] - attribute: href - download: - selector: td a[href^="download.php?id="] - attribute: href - # dates come in two flavours: - date_year: - # 13/10/2018 - selector: td:nth-child(6):contains("/") - # auto adjusted by site account profile - optional: true - filters: - - name: dateparse - args: "dd/MM/yyyy" - date_ago: - # 1 hour ago, 2 days ago, 3 weeks ago etc - selector: td:nth-child(6):not(:contains("/")) - # auto adjusted by site account profile - optional: true - filters: - - name: timeago - date: - text: "{{ if or .Result.date_year .Result.date_ago }}{{ or .Result.date_year .Result.date_ago }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - grabs: - selector: td:nth-child(11) - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# BtitTracker 1.3.1 customised diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rockethd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rockethd.yml deleted file mode 100644 index 728b21ba..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rockethd.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: rockethd -name: RocketHD (API) -description: "RocketHD is a GERMAN Private Torrent Tracker for MOVIES / TV" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://rocket-hd.cc/ -legacylinks: - - https://r0k3t.li/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your RocketHD account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: tmdbidonly - type: checkbox - label: Disable IMDB and TVDB ID search (only support TMDB ID) to potentially improve Sonarr and Radarr results - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If it has been 60 days since your last login, your account will be inactive. After another 30 days without logging in, your account will be Disabled!" - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.IMDBIDShort }}{{ end }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.TVDBID }}{{ end }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/romanianmetaltorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/romanianmetaltorrents.yml deleted file mode 100644 index eab2ced2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/romanianmetaltorrents.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: romanianmetaltorrents -replaces: - - metaliplayro -name: Romanian Metal Torrents -description: "Romanian Metal Torrents (RMT) is a Private Torrent Tracker dedicated to METAL MUSIC. This definition is for the English site." -language: en-US -type: private -encoding: UTF-8 -links: - - https://metal.iplay.ro/ -legacylinks: - - https://metal.stream.bike/ - -caps: - categorymappings: - - {id: 86, cat: Audio/Video, desc: "BluRay video"} - - {id: 84, cat: Audio/Video, desc: "HD video"} - - {id: 20, cat: Audio/Video, desc: "MusicDVD"} - - {id: 36, cat: Audio/Video, desc: "SD video"} - - {id: 63, cat: Audio, desc: "Discography/Album Pack"} - - {id: 65, cat: Audio, desc: "E-Learning"} - - {id: 51, cat: Audio, desc: "Alternative"} - - {id: 46, cat: Audio, desc: "Avant/Experimental"} - - {id: 38, cat: Audio, desc: "Black Metal"} - - {id: 45, cat: Audio, desc: "Darkwave/Ambient"} - - {id: 39, cat: Audio, desc: "Death Metal"} - - {id: 64, cat: Audio, desc: "Doom/Sludge"} - - {id: 40, cat: Audio, desc: "Gothic Metal"} - - {id: 71, cat: Audio, desc: "Grindcore"} - - {id: 85, cat: Audio, desc: "Groove"} - - {id: 74, cat: Audio, desc: "Hard Rock"} - - {id: 41, cat: Audio, desc: "Heavy Metal"} - - {id: 43, cat: Audio, desc: "Industrial/Goth"} - - {id: 73, cat: Audio, desc: "Melodic Death"} - - {id: 75, cat: Audio, desc: "Power Metal"} - - {id: 53, cat: Audio, desc: "Progressive Metal"} - - {id: 44, cat: Audio, desc: "Punk"} - - {id: 2, cat: Audio, desc: "Rock"} - - {id: 42, cat: Audio, desc: "Thrash Metal"} - - {id: 52, cat: Audio, desc: "Viking/Folk "} - - {id: 69, cat: Audio, desc: "[RMT Pack]"} - - {id: 54, cat: Audio, desc: "[V A/Compilation]"} - - modes: - search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: _ - options: - _: created - 7: seeders - 5: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "In order to keep your account here we advise you to access this site as often as possible. If you don't access it for 60 days, your account will be deleted. Please note that torrent traffic is not considered site activity - you have to sign in !" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img - input: vImageCodP - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: ":root:contains(\"Wrong Code!\")" - message: - text: The security code is incorrect. - - selector: table.main:contains("Login failed!") - test: - path: index.php - selector: a[href="logout.php"] - -download: - before: - path: login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 50 title, 100 description, 150 band - band is OR search instead of AND - tlt: 50 - # 0 active, 1 recommended, 2 incldead, 3 lossy, 4 lossless, 5 req, 6 free or half, 7 videos, 8 int RMT - incldead: "{{ if .Config.freeleech }}6{{ else }}2{{ end }}" - sort: "{{ re_replace .Config.sort \"_\" \"\" }}" - type: "{{ .Config.type }}" - - rows: - selector: table.torrents_table tbody tr:has(a[href^="download2.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download2.php?id="] - attribute: href - filters: - - name: replace - args: ["download2.php?id=", "download.php/"] - - name: append - args: "/{{ .Result.title }}.torrent" - poster: - selector: td:nth-child(2) a - attribute: onmouseover - filters: - - name: regexp - args: 'img src=(.+?) >' - - name: replace - args: ["./pic/noposter.jpg", ""] - date: - text: now - size: - selector: td:nth-child(6) - artist: - selector: span.bandpropaganda a font - optional: true - description: - selector: span.bandpropaganda - remove: font - seeders: - selector: td:nth-child(7) - filters: - - name: regexp - args: "(\\d+) seeders" - leechers: - selector: td:nth-child(7) - filters: - - name: regexp - args: "(\\d+) leechers" - grabs: - selector: td:nth-child(6) - filters: - - name: regexp - args: "(\\d+) times" - downloadvolumefactor: - case: - img[src$="pic/freedownload.gif"]: 0 - img[src$="pic/half.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="pic/sticky2.gif"]: 1.5 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rotorrent-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rotorrent-api.yml deleted file mode 100644 index ace6f3ee..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rotorrent-api.yml +++ /dev/null @@ -1,189 +0,0 @@ ---- -id: rotorrent-api -name: RoTorrent (API) -description: "RoTorrent (ROT) is a ROMANIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ro-RO -type: private -encoding: UTF-8 -links: - - https://rotorrent.info/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: PC/Games, desc: "Games"} - - {id: 5, cat: XXX, desc: "XXX"} - - {id: 6, cat: Other, desc: "Video"} - - {id: 7, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your RoTorrent account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rousi-pro.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rousi-pro.yml deleted file mode 100644 index 8d9e9645..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rousi-pro.yml +++ /dev/null @@ -1,146 +0,0 @@ ---- -id: rousi-pro -name: Rousi.pro -description: "Rousi.pro is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://rousi.pro/ - -caps: - categorymappings: - - {id: movie, cat: Movies, desc: "电影"} - - {id: tv, cat: TV, desc: "电视剧"} - - {id: documentary, cat: TV/Documentary, desc: "纪录片"} - - {id: animation, cat: TV/Anime, desc: "动漫"} - - {id: music, cat: Audio, desc: "音乐"} - - {id: variety, cat: TV, desc: "综艺"} - - {id: sports, cat: TV/Sport, desc: "体育"} - - {id: software, cat: PC, desc: "软件"} - - {id: ebook, cat: Books, desc: "电子书"} - - {id: 9kg, cat: XXX, desc: "9KG"} - - {id: other, cat: Other, desc: "其它"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: API key - - name: info_apikey - type: info - label: About your API key - default: "
  1. Login to this tracker with your browser
  2. Open the DevTools panel by pressing F12
  3. Select the Network tab
  4. Click on the Fetch/XHR button (Chrome Browser) or XHR button (FireFox)
  5. Perform any search on the web site
  6. Click on the first search?query= entry
  7. Select the Headers tab on the Right panel
  8. Find 'cookie:' in the Request Headers section
  9. Select and Copy the API key from the Authorization string (everything after 'Authorization: Bearer ') and Paste here.
" - - name: passkey - type: text - label: Passkey - - name: info_passkey - type: info - label: About your Passkey - default: "You can find the Passkey on your Passkey settings page." - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - size: size - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules (Inactive Accounts):
  1. Not logged in for more than 30 days: Send email reminders
  2. Not logged in for more than 60 days: 5000 Mana points are deducted daily
  3. After the mana value is deducted to 0: the account is banned
  4. If the user expects to be unable to log in for a period of time, they can pay in advance 150,000 mana to protect account for 60 days
" - -login: - path: api/torrent/latest - method: get - -search: - paths: - - path: api/torrent/search - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - query: "{{ .Keywords }}" - # "" both, title title, subtitle desc - search_field: title - sort: "{{ .Config.sort }}+{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: ["[\\.\\s]", "%"] - - rows: - selector: data.torrents - - fields: - _id: - selector: uuid - title: - selector: title - category: - selector: type - details: - text: "{{ .Config.sitelink }}torrent/{{ .Result._id }}" - download: - text: "{{ .Config.sitelink }}api/torrent/{{ .Result._id }}/download/{{ .Config.passkey }}" - infohash: - selector: info_hash - poster: - selector: poster - imdbid: - selector: attributes.imdb_id - tmdbid: - selector: attributes.tmdb_id - doubanid: - selector: attributes.douban_id - description: - selector: subtitle - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: downloads - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - genre: - selector: genre - downloadvolumefactor: - selector: promotion.down_multiplier - optional: true - default: 1 - uploadvolumefactor: - selector: promotion.up_multiplier - optional: true - default: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rudub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rudub.yml deleted file mode 100644 index c1449747..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rudub.yml +++ /dev/null @@ -1,224 +0,0 @@ ---- -id: rudub -replaces: - - baibako -name: RUDUB -description: "RUDUB (ex-BaibaKoTV) is a RUSSIAN Semi-Private Torrent Tracker for TV" -language: ru-RU -type: semi-private -encoding: windows-1251 -followredirect: true -links: - - https://r2.rudub.world/ -legacylinks: - - http://dec12.rudub.bet/ - - https://dec12.rudub.bet/ - - http://dec13.rudub.bet/ - - https://dec13.rudub.bet/ - - http://dec14.rudub.bet/ - - https://dec14.rudub.bet/ - - http://dec15.rudub.bet/ - - https://dec15.rudub.bet/ - - http://dec16.rudub.bet/ - - https://dec16.rudub.bet/ - - http://dec17.rudub.bet/ - - https://dec17.rudub.bet/ - - http://dec18.rudub.bet/ - - https://dec18.rudub.bet/ - - http://dec19.rudub.bet/ - - https://dec19.rudub.bet/ - - http://dec20.rudub.bet/ - - https://dec20.rudub.bet/ - - http://dec21.rudub.bet/ - - https://dec21.rudub.bet/ - - http://dec22.rudub.bet/ - - https://dec22.rudub.bet/ - - http://dec23.rudub.bet/ - - https://dec23.rudub.bet/ - - http://dec24.rudub.bet/ - - https://dec24.rudub.bet/ - - http://dec25.rudub.bet/ - - https://dec25.rudub.bet/ - - http://dec26.rudub.bet/ - - https://dec26.rudub.bet/ -caps: - categorymappings: - - {id: 1, cat: TV, desc: "TV"} - modes: - search: [q] - tv-search: [q, season, ep] - allowrawsearch: true -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.login2 - test: - path: browse.php - selector: a[href="logout.php"] -search: - paths: - # http://rudub.tv/browse.php?search=&incldead=1&sort=4&type=desc - - path: browse.php - inputs: - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 onlydead, 3 gold, 4 seedfree - incldead: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - rows: - selector: div.card__torlist__browse_2 - fields: - category: - text: 1 - title: - selector: a[href^="details.php?id="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: replace - args: ["BD720p", "BD 720p"] - - name: replace - args: ["HD720p", "HD 720p"] - - name: replace - args: ["HDR720p", "HDR 720p"] - - name: replace - args: ["BD1080p", "BD 1080p"] - - name: replace - args: ["HD1080p", "HD 1080p"] - - name: replace - args: ["HDR1080p", "HDR 1080p"] - - name: replace - args: [" (Золото)", ""] # remove gold tag - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["^\\((.+?)\\s?\\)", "$1 "] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - date: - # 2022-03-28 01:28:07 - selector: li[title="Дата"] - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - files: - selector: li[title="Файлов"] - size: - selector: li[title="Размер"] - seeders: - selector: li[title="Активность"] - filters: - - name: regexp - args: (\d+)\s+\d+ - leechers: - selector: li[title="Активность"] - filters: - - name: regexp - args: \d+\s+(\d+) - grabs: - selector: li[title="Завершено"] - downloadvolumefactor: - case: - "a[href^=\"details.php?id=\"]:contains(\"(Золото)\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.3 - description: - selector: a[href^="details.php?id="] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rustorka.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rustorka.yml deleted file mode 100644 index 0505fd52..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rustorka.yml +++ /dev/null @@ -1,980 +0,0 @@ ---- -id: rustorka -name: Rustorka -description: "Rustorka is a RUSSIAN Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://rustorka.com/ -legacylinks: - - http://rustorka.com/ - -caps: - categorymappings: - # Игры для PC - # Games for PC - - {id: 1398, cat: PC/Games, desc: "Горячие Новинки"} - - {id: 1399, cat: PC/Games, desc: " |- Горячие RePack'и"} - - {id: 393, cat: PC/Games, desc: "Demo, Alpha, Beta версии"} - - {id: 824, cat: PC/Games, desc: "Steam / Origin Rip's от нашей R.G. GameWorks"} - - {id: 837, cat: PC/Games, desc: " |- Аркады"} - - {id: 836, cat: PC/Games, desc: " |- Приключения и квесты"} - - {id: 835, cat: PC/Games, desc: " |- TBS (пошаговые стратегии)"} - - {id: 834, cat: PC/Games, desc: " |- RTS (стратегии в реальном времени)"} - - {id: 833, cat: PC/Games, desc: " |- Экономические стратегии"} - - {id: 832, cat: PC/Games, desc: " |- Спортивные симуляторы"} - - {id: 831, cat: PC/Games, desc: " |- Авиа/Космические симуляторы"} - - {id: 830, cat: PC/Games, desc: " |- Гоночные симуляторы"} - - {id: 839, cat: PC/Games, desc: " |- Прочие симуляторы"} - - {id: 829, cat: PC/Games, desc: " |- FPS (1st Person)"} - - {id: 828, cat: PC/Games, desc: " |- TPS (3rd Person)"} - - {id: 1948, cat: PC/Games, desc: " |- Horror"} - - {id: 1949, cat: PC/Games, desc: " |- Stealth"} - - {id: 827, cat: PC/Games, desc: " |- RPG"} - - {id: 838, cat: PC/Games, desc: " |- Онлайновые игры"} - - {id: 826, cat: PC/Games, desc: " |- Гонки"} - - {id: 825, cat: PC/Games, desc: " |- Мини игры"} - - {id: 1267, cat: PC/Games, desc: "Steam-Rip's"} - - {id: 1283, cat: PC/Games, desc: " |- Аркады"} - - {id: 1282, cat: PC/Games, desc: " |- Приключения и квесты"} - - {id: 1281, cat: PC/Games, desc: " |- TBS (пошаговые стратегии)"} - - {id: 1280, cat: PC/Games, desc: " |- RTS (стратегии в реальном времени)"} - - {id: 1279, cat: PC/Games, desc: " |- Экономические стратегии"} - - {id: 1278, cat: PC/Games, desc: " |- Спортивные симуляторы"} - - {id: 1277, cat: PC/Games, desc: " |- Авиа/Космические симуляторы"} - - {id: 1276, cat: PC/Games, desc: " |- Гоночные симуляторы"} - - {id: 1275, cat: PC/Games, desc: " |- Прочие симуляторы"} - - {id: 1273, cat: PC/Games, desc: " |- FPS (1st Person)"} - - {id: 1272, cat: PC/Games, desc: " |- TPS (3rd Person)"} - - {id: 1950, cat: PC/Games, desc: " |- Horror"} - - {id: 1951, cat: PC/Games, desc: " |- Stealth"} - - {id: 1271, cat: PC/Games, desc: " |- RPG"} - - {id: 1270, cat: PC/Games, desc: " |- Онлайновые игры"} - - {id: 1269, cat: PC/Games, desc: " |- Гонки"} - - {id: 1268, cat: PC/Games, desc: " |- Мини игры"} - - {id: 1274, cat: PC/Games, desc: " |- Детские игры"} - - {id: 1318, cat: PC/Games, desc: "Origin-Rip's"} - - {id: 1334, cat: PC/Games, desc: " |- Аркады"} - - {id: 1333, cat: PC/Games, desc: " |- Приключения и квесты"} - - {id: 1332, cat: PC/Games, desc: " |- TBS (пошаговые стратегии)"} - - {id: 1331, cat: PC/Games, desc: " |- RTS (стратегии в реальном времени)"} - - {id: 1330, cat: PC/Games, desc: " |- Экономические стратегии"} - - {id: 1329, cat: PC/Games, desc: " |- Спортивные симуляторы"} - - {id: 1328, cat: PC/Games, desc: " |- Авиа/Космические симуляторы"} - - {id: 1327, cat: PC/Games, desc: " |- Гоночные симуляторы"} - - {id: 1326, cat: PC/Games, desc: " |- Прочие симуляторы"} - - {id: 1325, cat: PC/Games, desc: " |- FPS (1st Person)"} - - {id: 1324, cat: PC/Games, desc: " |- TPS (3rd Person)"} - - {id: 1952, cat: PC/Games, desc: " |- Horror"} - - {id: 1953, cat: PC/Games, desc: " |- Stealth"} - - {id: 1323, cat: PC/Games, desc: " |- RPG"} - - {id: 1322, cat: PC/Games, desc: " |- Онлайновые игры"} - - {id: 1321, cat: PC/Games, desc: " |- Гонки"} - - {id: 1320, cat: PC/Games, desc: " |- Мини игры"} - - {id: 1319, cat: PC/Games, desc: " |- Детские игры"} - - {id: 2003, cat: PC/Games, desc: "Релизы от R.G. GOGFAN (Windows)"} - - {id: 2006, cat: PC/Games, desc: " |- Информация по разделу"} - - {id: 2007, cat: PC/Games, desc: " |- Горячие новинки"} - - {id: 2008, cat: PC/Games, desc: " |- Ранний доступ"} - - {id: 2009, cat: PC/Games, desc: " |- Action"} - - {id: 2019, cat: PC/Games, desc: " |- RPG"} - - {id: 2018, cat: PC/Games, desc: " |- Аркады"} - - {id: 2017, cat: PC/Games, desc: " |- Квесты и приключения"} - - {id: 2016, cat: PC/Games, desc: " |- Стратегии"} - - {id: 2015, cat: PC/Games, desc: " |- Симуляторы"} - - {id: 2012, cat: PC/Games, desc: " |- Дополнительные материалы"} - - {id: 1984, cat: PC/Games, desc: "Релизы с GOG.com (игры без защиты)"} - - {id: 1985, cat: PC/Games, desc: " |- Action"} - - {id: 1986, cat: PC/Games, desc: " |- Fighting"} - - {id: 1987, cat: PC/Games, desc: " |- RPG"} - - {id: 1988, cat: PC/Games, desc: " |- Аркады"} - - {id: 1989, cat: PC/Games, desc: " |- Квесты и приключения"} - - {id: 1990, cat: PC/Games, desc: " |- Стратегии"} - - {id: 1991, cat: PC/Games, desc: " |- Симуляторы"} - - {id: 1992, cat: PC/Games, desc: " |- Гонки"} - - {id: 1993, cat: PC/Games, desc: " |- Update"} - - {id: 1994, cat: PC/Games, desc: " |- DLC"} - - {id: 1995, cat: PC/Games, desc: " |- Demo, Alpha, Beta"} - - {id: 1015, cat: PC/Games, desc: "Антологии"} - - {id: 1030, cat: PC/Games, desc: " |- Аркады"} - - {id: 1029, cat: PC/Games, desc: " |- Приключения и квесты"} - - {id: 1028, cat: PC/Games, desc: " |- TBS (пошаговые стратегии)"} - - {id: 1027, cat: PC/Games, desc: " |- RTS (стратегии в реальном времени)"} - - {id: 1026, cat: PC/Games, desc: " |- Экономические стратегии"} - - {id: 1025, cat: PC/Games, desc: " |- Спортивные симуляторы"} - - {id: 1024, cat: PC/Games, desc: " |- Авиа/Космические симуляторы"} - - {id: 1023, cat: PC/Games, desc: " |- Гоночные симуляторы"} - - {id: 1022, cat: PC/Games, desc: " |- Прочие симуляторы"} - - {id: 1021, cat: PC/Games, desc: " |- FPS (1st Person)"} - - {id: 1020, cat: PC/Games, desc: " |- TPS (3rd Person)"} - - {id: 1954, cat: PC/Games, desc: " |- Horror"} - - {id: 1955, cat: PC/Games, desc: " |- Stealth"} - - {id: 1019, cat: PC/Games, desc: " |- RPG"} - - {id: 1018, cat: PC/Games, desc: " |- Онлайновые игры"} - - {id: 1017, cat: PC/Games, desc: " |- Гонки"} - - {id: 1016, cat: PC/Games, desc: " |- Мини игры"} - - {id: 1149, cat: PC/Games, desc: " |- Детские игры"} - - {id: 73, cat: PC/Games, desc: "Аркады"} - - {id: 74, cat: PC/Games, desc: "Приключения и квесты"} - - {id: 75, cat: PC/Games, desc: "Стратегии"} - - {id: 77, cat: PC/Games, desc: " |- TBS (пошаговые стратегии)"} - - {id: 76, cat: PC/Games, desc: " |- RTS (стратегии в реальном времени)"} - - {id: 78, cat: PC/Games, desc: " |- Экономические стратегии"} - - {id: 80, cat: PC/Games, desc: "Симуляторы"} - - {id: 81, cat: PC/Games, desc: " |- Спортивные симуляторы"} - - {id: 82, cat: PC/Games, desc: " |- Авиа/Космические симуляторы"} - - {id: 83, cat: PC/Games, desc: " |- Гоночные симуляторы"} - - {id: 840, cat: PC/Games, desc: " |- Прочие симуляторы"} - - {id: 84, cat: PC/Games, desc: "Action"} - - {id: 85, cat: PC/Games, desc: " |- FPS (1st Person)"} - - {id: 86, cat: PC/Games, desc: " |- TPS (3rd Person)"} - - {id: 1946, cat: PC/Games, desc: " |- Horror"} - - {id: 1947, cat: PC/Games, desc: " |- Stealth"} - - {id: 1788, cat: PC/Games, desc: " |- S.T.A.L.K.E.R."} - - {id: 87, cat: PC/Games, desc: "RPG"} - - {id: 92, cat: PC/Games, desc: "Гонки"} - - {id: 89, cat: PC/Games, desc: "Онлайновые игры"} - - {id: 90, cat: PC/Games, desc: " |- MMORPG"} - - {id: 791, cat: PC/Games, desc: " |- Lineage II"} - - {id: 792, cat: PC/Games, desc: " |- World of Warcraft"} - - {id: 793, cat: PC/Games, desc: " |- FPS / TPS (1st / 3rd Person)"} - - {id: 1143, cat: PC/Games, desc: " |- Counter-Strike"} - - {id: 794, cat: PC/Games, desc: " |- Action с элементами RPG"} - - {id: 1260, cat: PC/Games, desc: " |- Патчи и Апдейты"} - - {id: 91, cat: PC/Games, desc: "Детские игры"} - - {id: 636, cat: PC/Games, desc: "Мини игры"} - - {id: 93, cat: PC/Games, desc: "Старые игры для PC"} - - {id: 795, cat: PC/Games, desc: " |- Симуляторы (Старые игры)"} - - {id: 94, cat: PC/Games, desc: " |- Аркады (Старые игры)"} - - {id: 95, cat: PC/Games, desc: " |- Приключения и квесты (Старые игры)"} - - {id: 96, cat: PC/Games, desc: " |- Стратегии (Старые игры)"} - - {id: 97, cat: PC/Games, desc: " |- Action (Старые игры)"} - - {id: 98, cat: PC/Games, desc: " |- RPG (Старые игры)"} - - {id: 2004, cat: PC/Games, desc: "Релизы от R.G. GOGFAN (macOS)"} - - {id: 2033, cat: PC/Games, desc: " |- Информация по разделу"} - - {id: 2032, cat: PC/Games, desc: " |- Горячие новинки"} - - {id: 2031, cat: PC/Games, desc: " |- Ранний доступ"} - - {id: 2030, cat: PC/Games, desc: " |- Action"} - - {id: 2028, cat: PC/Games, desc: " |- RPG"} - - {id: 2027, cat: PC/Games, desc: " |- Аркады"} - - {id: 2026, cat: PC/Games, desc: " |- Квесты и приключения"} - - {id: 2025, cat: PC/Games, desc: " |- Стратегии"} - - {id: 2024, cat: PC/Games, desc: " |- Симуляторы"} - - {id: 2005, cat: PC/Games, desc: "Релизы от R.G. GOGFAN (Linux)"} - - {id: 2047, cat: PC/Games, desc: " |- Информация по разделу"} - - {id: 2046, cat: PC/Games, desc: " |- Горячие новинки"} - - {id: 2045, cat: PC/Games, desc: " |- Ранний доступ"} - - {id: 2044, cat: PC/Games, desc: " |- Action"} - - {id: 2042, cat: PC/Games, desc: " |- RPG"} - - {id: 2041, cat: PC/Games, desc: " |- Аркады"} - - {id: 2040, cat: PC/Games, desc: " |- Квесты и приключения"} - - {id: 2039, cat: PC/Games, desc: " |- Стратегии"} - - {id: 2038, cat: PC/Games, desc: " |- Симуляторы"} - - {id: 99, cat: PC/Games, desc: "Игры для других ОС"} - - {id: 100, cat: PC/Games, desc: " |- Игры для Mac OS (Macintosh)"} - - {id: 101, cat: PC/Games, desc: " |- Игры для Linux и Unix-подобных (*nix) OS"} - - {id: 102, cat: PC/Games, desc: "Прочее"} - - {id: 107, cat: PC/Games, desc: " |- Патчи"} - - {id: 103, cat: PC/Games, desc: " |- Дополнения к играм"} - - {id: 1090, cat: PC/Games, desc: " |- Мап-Паки к играм"} - - {id: 104, cat: PC/Games, desc: " |- Русификаторы"} - - {id: 1091, cat: PC/Games, desc: " |- Бонусные диски / Комплекты предзаказов"} - - {id: 106, cat: PC/Games, desc: " |- Игровые журналы"} - - {id: 108, cat: PC/Games, desc: "Игровое видео"} - - {id: 109, cat: PC/Games, desc: " |- Трейлеры/Геймплей видео"} - - {id: 110, cat: PC/Games, desc: " |- Видео прохождения игр"} - - {id: 760, cat: PC/Games, desc: " |- Lineage II Movies"} - - {id: 2001, cat: PC/Games, desc: "VR"} - - {id: 1999, cat: PC/Games, desc: "Скрытый"} - # Игры для Консолей - # Games for Consoles - - {id: 295, cat: Console, desc: "XBox"} - - {id: 578, cat: Console, desc: " |- XBox360 | Демоверсии"} - - {id: 1412, cat: Console, desc: " |- XBox360 | Игры"} - - {id: 1413, cat: Console, desc: " |- XBox360 | Kinect Игры"} - - {id: 1032, cat: Console, desc: " |- XBox360 | JTAG"} - - {id: 1087, cat: Console, desc: " |- Xbox360 | Live"} - - {id: 776, cat: Console, desc: " |- XBox360 | Программы / Прошивки"} - - {id: 296, cat: Console, desc: " |- XBox | Игры"} - - {id: 298, cat: Console, desc: "PlayStation"} - - {id: 1998, cat: Console, desc: " |- Playstation 4 | Игры"} - - {id: 1354, cat: Console, desc: " |- Playstation 3 | Демоверсии"} - - {id: 1897, cat: Console, desc: " |- Playstation 3 | Игры"} - - {id: 1138, cat: Console, desc: " |- Playstation 3 | Move Игры"} - - {id: 1262, cat: Console, desc: " |- Playstation 3 | PSN Игры"} - - {id: 1099, cat: Console, desc: " |- Playstation 3 | Разное"} - - {id: 1353, cat: Console, desc: " |- Playstation 3 | Программы / Прошивки"} - - {id: 301, cat: Console, desc: " |- PSP | Игры"} - - {id: 300, cat: Console, desc: " |- PSP | Программы / Прошивки"} - - {id: 620, cat: Console, desc: " |- PSX-PSP"} - - {id: 619, cat: Console, desc: " |- Homebrew игры"} - - {id: 451, cat: Console, desc: " |- PlayStation One | Игры"} - - {id: 299, cat: Console, desc: " |- PlayStation 2 | Игры"} - - {id: 302, cat: Console, desc: "Nintendo"} - - {id: 2002, cat: Console, desc: " |- Switch | Игры"} - - {id: 305, cat: Console, desc: " |- Nintendo DS | Игры"} - - {id: 1285, cat: Console, desc: " |- Nintendo 3DS | Игры"} - - {id: 464, cat: Console, desc: " |- GameCube | Игры"} - - {id: 303, cat: Console, desc: " |- Wii | Игры"} - - {id: 306, cat: Console, desc: "Sega"} - - {id: 307, cat: Console, desc: " |- Dreamcast | Игры"} - - {id: 308, cat: Console, desc: "Старые игры для Консолей"} - # Фильмы | Мультфильмы - # Films | Cartoons - - {id: 1401, cat: Movies, desc: "Новинки кинопроката 2020 (фильмы / мультфильмы)"} - - {id: 1411, cat: Movies, desc: " |- CamRip"} - - {id: 1410, cat: Movies, desc: " |- TeleSync"} - - {id: 1408, cat: Movies, desc: " |- SatRip"} - - {id: 1407, cat: Movies, desc: " |- DVDScreener"} - - {id: 1406, cat: Movies, desc: " |- WEBRip / HDTVRip"} - - {id: 1974, cat: Movies, desc: " |- WEB-DL / WEB-DLRip"} - - {id: 1405, cat: Movies, desc: " |- DVDRip / DVD"} - - {id: 1403, cat: Movies, desc: " |- HDRip"} - - {id: 1817, cat: Movies, desc: " |- HDRip / BDRip-AVC"} - - {id: 1402, cat: Movies, desc: " |- BDRip / Full HD (720p / 1080p / Ultra HD (4K))"} - - {id: 1786, cat: Movies, desc: " |- Blu-Ray / BDRemux"} - - {id: 1982, cat: Movies, desc: " |- HEVC"} - - {id: 1826, cat: Movies, desc: " |- 3D"} - - {id: 1983, cat: Movies, desc: " |- На иностранном языке"} - - {id: 1116, cat: Movies, desc: "3D Видео / фильмы / мультфильмы"} - - {id: 1133, cat: Movies, desc: " |- Боевик"} - - {id: 1132, cat: Movies, desc: " |- Фэнтези"} - - {id: 1131, cat: Movies, desc: " |- Фантастика"} - - {id: 1130, cat: Movies, desc: " |- Детский / Семейный"} - - {id: 1129, cat: Movies, desc: " |- Триллер"} - - {id: 1128, cat: Movies, desc: " |- Приключения / Исторический"} - - {id: 1127, cat: Movies, desc: " |- Драма"} - - {id: 1126, cat: Movies, desc: " |- Классика советского кино"} - - {id: 1125, cat: Movies, desc: " |- Классика мирового кинематографа"} - - {id: 1124, cat: Movies, desc: " |- Детектив"} - - {id: 1123, cat: Movies, desc: " |- Мистика"} - - {id: 1122, cat: Movies, desc: " |- Мелодрама"} - - {id: 1121, cat: Movies, desc: " |- Вестерн"} - - {id: 1120, cat: Movies, desc: " |- Ужасы"} - - {id: 1118, cat: Movies, desc: " |- Комедия"} - - {id: 1117, cat: Movies, desc: " |- Арт-хаус / Авторское кино"} - - {id: 1976, cat: Movies, desc: " |- Мультфильмы"} - - {id: 1809, cat: Movies, desc: " |- Ролики / Музыкальное видео / Трейлеры к фильмам"} - - {id: 579, cat: Movies, desc: "Зарубежное кино (HD / Blu-Ray / HDRip / BDRip 720p/1080p/Ult.."} - - {id: 1822, cat: Movies, desc: " |- 60fps"} - - {id: 597, cat: Movies, desc: " |- Боевик"} - - {id: 596, cat: Movies, desc: " |- Фэнтези"} - - {id: 595, cat: Movies, desc: " |- Фантастика"} - - {id: 594, cat: Movies, desc: " |- Детский / Семейный"} - - {id: 593, cat: Movies, desc: " |- Триллер"} - - {id: 592, cat: Movies, desc: " |- Приключения / Исторический"} - - {id: 591, cat: Movies, desc: " |- Драма"} - - {id: 589, cat: Movies, desc: " |- Классика мирового кинематографа"} - - {id: 588, cat: Movies, desc: " |- Детектив"} - - {id: 587, cat: Movies, desc: " |- Мистика"} - - {id: 586, cat: Movies, desc: " |- Мелодрама"} - - {id: 585, cat: Movies, desc: " |- Вестерн"} - - {id: 584, cat: Movies, desc: " |- Ужасы"} - - {id: 582, cat: Movies, desc: " |- Комедия"} - - {id: 581, cat: Movies, desc: " |- Арт-хаус / Авторское кино"} - - {id: 1978, cat: Movies, desc: " |- Театр"} - - {id: 18, cat: Movies, desc: "Зарубежное кино (DVD / DVDRip / WEB-DLRip / HDRip / HDRip-AV.."} - - {id: 20, cat: Movies, desc: " |- DVD 4.7 GB / 8.5 GB"} - - {id: 21, cat: Movies, desc: " |- Боевик"} - - {id: 22, cat: Movies, desc: " |- Фэнтези"} - - {id: 23, cat: Movies, desc: " |- Фантастика"} - - {id: 24, cat: Movies, desc: " |- Детский / Семейный"} - - {id: 25, cat: Movies, desc: " |- Триллер"} - - {id: 26, cat: Movies, desc: " |- Приключения / Исторический"} - - {id: 27, cat: Movies, desc: " |- Драма"} - - {id: 29, cat: Movies, desc: " |- Классика мирового кинематографа"} - - {id: 30, cat: Movies, desc: " |- Детектив"} - - {id: 31, cat: Movies, desc: " |- Мистика"} - - {id: 32, cat: Movies, desc: " |- Мелодрама"} - - {id: 33, cat: Movies, desc: " |- Вестерн"} - - {id: 1397, cat: Movies, desc: " |- Ужасы"} - - {id: 36, cat: Movies, desc: " |- Комедия"} - - {id: 790, cat: Movies, desc: " |- Индийское кино"} - - {id: 37, cat: Movies, desc: " |- Арт-хаус / Авторское кино"} - - {id: 1977, cat: Movies, desc: " |- Театр"} - - {id: 580, cat: Movies, desc: "Российское кино"} - - {id: 616, cat: Movies, desc: " |- DVD 4.7 GB / 8.5 GB"} - - {id: 614, cat: Movies, desc: " |- Боевик"} - - {id: 613, cat: Movies, desc: " |- Фэнтези"} - - {id: 612, cat: Movies, desc: " |- Фантастика"} - - {id: 611, cat: Movies, desc: " |- Детский / Семейный"} - - {id: 610, cat: Movies, desc: " |- Триллер"} - - {id: 609, cat: Movies, desc: " |- Приключения / Исторический"} - - {id: 608, cat: Movies, desc: " |- Драма"} - - {id: 607, cat: Movies, desc: " |- Классика советского кино"} - - {id: 606, cat: Movies, desc: " |- Детектив"} - - {id: 605, cat: Movies, desc: " |- Мистика"} - - {id: 604, cat: Movies, desc: " |- Мелодрама"} - - {id: 603, cat: Movies, desc: " |- Вестерн"} - - {id: 602, cat: Movies, desc: " |- Ужасы"} - - {id: 600, cat: Movies, desc: " |- Комедия"} - - {id: 599, cat: Movies, desc: " |- Арт-хаус / Авторское кино"} - - {id: 1979, cat: Movies, desc: " |- Театр"} - - {id: 44, cat: Movies, desc: "Мультфильмы"} - - {id: 2000, cat: Movies, desc: " |- 60fps"} - - {id: 48, cat: Movies, desc: " |- Мультфильмы HD"} - - {id: 854, cat: Movies, desc: " |- Короткометражные мультфильмы"} - - {id: 50, cat: Movies, desc: " |- Зарубежные полнометражные мультфильмы"} - - {id: 49, cat: Movies, desc: " |- Отечественные полнометражные мультфильмы"} - - {id: 1980, cat: Movies, desc: " |- Классика"} - - {id: 452, cat: Movies, desc: "Дилогии / Трилогии / Квадралогии"} - - {id: 1831, cat: Movies, desc: " |- DVDRip / HDRip / BDRip / AVC"} - - {id: 1830, cat: Movies, desc: " |- BDRip 720"} - - {id: 1829, cat: Movies, desc: " |- BDRip 1080 / BDRemux"} - - {id: 1828, cat: Movies, desc: " |- BluRay"} - - {id: 1827, cat: Movies, desc: " |- Фильмографии"} - - {id: 1825, cat: Movies, desc: "Фильмы для iOS / Android / КПК"} - # Великая Отечественная Война - # World War II - - {id: 1958, cat: Movies, desc: "Фильмы"} - - {id: 1959, cat: Movies, desc: "Сериалы"} - # Сериалы | Мультсериалы - # Series | Cartoons - - {id: 1862, cat: TV, desc: "Новинки (сериалы / мультсериалы)"} - - {id: 1867, cat: TV, desc: " |- SatRip"} - - {id: 1866, cat: TV, desc: " |- WEB DLRip / WEB DLRip-AVC"} - - {id: 1865, cat: TV, desc: " |- HDTVRip / HDTVRip-AVC"} - - {id: 1864, cat: TV, desc: " |- WEB DL / HDTV 720"} - - {id: 1863, cat: TV, desc: " |- WEB DL / HDTV 1080"} - - {id: 2059, cat: TV, desc: " |- WEB DL / HDTV 2160p"} - - {id: 1855, cat: TV, desc: "Американские"} - - {id: 1861, cat: TV, desc: " |- DVDRip"} - - {id: 1860, cat: TV, desc: " |- SatRip"} - - {id: 1859, cat: TV, desc: " |- WEB DLRip / WEB DLRip-AVC"} - - {id: 1858, cat: TV, desc: " |- HDTVRip / HDRip / AVC"} - - {id: 1857, cat: TV, desc: " |- WEB DL / HD / BDRip 720"} - - {id: 1856, cat: TV, desc: " |- WEB DL / HD / BDRip 1080"} - - {id: 2055, cat: TV, desc: " |- WEB DL / HDTV 2160p"} - - {id: 1848, cat: TV, desc: "Европейские / Другие"} - - {id: 1854, cat: TV, desc: " |- DVDRip"} - - {id: 1853, cat: TV, desc: " |- SatRip"} - - {id: 1852, cat: TV, desc: " |- WEB DLRip / WEB DLRip-AVC"} - - {id: 1851, cat: TV, desc: " |- HDTVRip / HDRip / AVC"} - - {id: 1850, cat: TV, desc: " |- WEB DL / HD / BDRip 720"} - - {id: 1849, cat: TV, desc: " |- WEB DL / HD / BDRip 1080"} - - {id: 2056, cat: TV, desc: " |- WEB DL / HDTV 2160p"} - - {id: 1841, cat: TV, desc: "Российские"} - - {id: 1847, cat: TV, desc: " |- DVDRip"} - - {id: 1846, cat: TV, desc: " |- SatRip"} - - {id: 1845, cat: TV, desc: " |- WEB DLRip / WEB DLRip-AVC"} - - {id: 1844, cat: TV, desc: " |- HDTVRip / HDRip / AVC"} - - {id: 1843, cat: TV, desc: " |- WEB DL / HD / BDRip 720"} - - {id: 1842, cat: TV, desc: " |- WEB DL / HD / BDRip 1080"} - - {id: 2057, cat: TV, desc: " |- WEB DL / HDTV 2160p"} - - {id: 1871, cat: TV, desc: "Мультсериалы"} - - {id: 1877, cat: TV, desc: " |- DVDRip"} - - {id: 1876, cat: TV, desc: " |- SatRip"} - - {id: 1875, cat: TV, desc: " |- WEB DLRip / WEB DLRip-AVC"} - - {id: 1874, cat: TV, desc: " |- HDTVRip / HDRip / AVC"} - - {id: 1873, cat: TV, desc: " |- WEB DL / HD / BDRip 720"} - - {id: 1872, cat: TV, desc: " |- WEB DL / HD / BDRip 1080"} - - {id: 2058, cat: TV, desc: " |- WEB DL / HDTV 2160p"} - # Аниме - # Anime - - {id: 1879, cat: TV/Anime, desc: "Аниме"} - - {id: 1883, cat: TV/Anime, desc: " |- Новинки"} - - {id: 1882, cat: TV/Anime, desc: " |- Дорамы"} - - {id: 1881, cat: TV/Anime, desc: " |- Манга"} - - {id: 1880, cat: TV/Anime, desc: " |- AMV"} - - {id: 1890, cat: TV/Anime, desc: "Основной раздел"} - - {id: 1893, cat: TV/Anime, desc: " |- HDTVRip / HDTV 720 / 1080"} - - {id: 1892, cat: TV/Anime, desc: " |- DVD / DVDRip"} - - {id: 1891, cat: TV/Anime, desc: " |- BDRip / HDRip / AVC"} - - {id: 653, cat: TV/Anime, desc: " |- TVRip / VHSRip / Другие"} - - {id: 314, cat: TV/Anime, desc: " |- PSP"} - - {id: 659, cat: TV/Anime, desc: "OST"} - - {id: 661, cat: TV/Anime, desc: " |- mp3"} - - {id: 660, cat: TV/Anime, desc: " |- lossless"} - - {id: 662, cat: TV/Anime, desc: "Обои на рабочий стол/картинки/арты"} - - {id: 642, cat: TV/Anime, desc: "Флудилка"} - # Документалистика, Юмор, Спорт - # Documentary, Comedy, Sports - - {id: 1918, cat: TV/Documentary, desc: "Документальные фильмы и передачи"} - - {id: 1943, cat: TV/Documentary, desc: " |- 3D Фильмы, HD (720р / 1080р)"} - - {id: 1942, cat: TV/Documentary, desc: " |- Авиация"} - - {id: 1941, cat: TV/Documentary, desc: " |- Космос"} - - {id: 1940, cat: TV/Documentary, desc: " |- Криминалистика"} - - {id: 1939, cat: TV/Documentary, desc: " |- Вторая мировая война"} - - {id: 1938, cat: TV/Documentary, desc: " |- Военное дело"} - - {id: 1937, cat: TV/Documentary, desc: " |- Медицина и здоровье"} - - {id: 1936, cat: TV/Documentary, desc: " |- Путешествия и туризм"} - - {id: 1935, cat: TV/Documentary, desc: " |- История / Мировая культура / Религия"} - - {id: 1934, cat: TV/Documentary, desc: " |- Растительный и животный мир"} - - {id: 1933, cat: TV/Documentary, desc: " |- Личности и кумиры"} - - {id: 1932, cat: TV/Documentary, desc: " |- Научно-популярные и познавательные фильмы"} - - {id: 1931, cat: TV/Documentary, desc: " |- BBC / Discovery / National Geographic"} - - {id: 1930, cat: TV/Documentary, desc: " |- Информационно-аналитические и политические передачи"} - - {id: 1919, cat: TV/Documentary, desc: "Развлекательные передачи, шоу и юмор"} - - {id: 1929, cat: TV/Documentary, desc: " |- Юмористические программы и шоу"} - - {id: 1928, cat: TV/Documentary, desc: " |- КВН"} - - {id: 1927, cat: TV/Documentary, desc: " |- Уральские пельмени"} - - {id: 1925, cat: TV/Documentary, desc: " |- Авторские передачи"} - - {id: 1920, cat: TV/Documentary, desc: "Спорт"} - - {id: 1924, cat: TV/Documentary, desc: " |- Футбол"} - - {id: 1923, cat: TV/Documentary, desc: " |- Хоккей"} - - {id: 1922, cat: TV/Documentary, desc: " |- Формула 1"} - - {id: 1921, cat: TV/Documentary, desc: " |- Другие виды спорта"} - # Обучающее видео - # Tutorial video - - {id: 240, cat: Other, desc: "Обучающие видеокурсы и уроки"} - - {id: 246, cat: Other, desc: " |- Здоровье и медицина"} - - {id: 241, cat: Other, desc: " |- Психология и саморазвитие"} - - {id: 245, cat: Other, desc: " |- Восточные оздоровительные практики"} - - {id: 855, cat: Other, desc: " |- Спорт, фитнес"} - - {id: 1944, cat: Other, desc: " |- Боевые искусства"} - - {id: 244, cat: Other, desc: " |- Игра на гитаре"} - - {id: 243, cat: Other, desc: " |- Бизнес, экономика и финансы"} - - {id: 242, cat: Other, desc: " |- Учебные видео для детей и родителей"} - - {id: 1812, cat: Other, desc: " |- Строительство и ремонт, сделай сам"} - - {id: 1813, cat: Other, desc: " |- Кулинария"} - - {id: 1814, cat: Other, desc: " |- Фотография, работа с фото"} - - {id: 1815, cat: Other, desc: " |- Прочие видеокурсы и уроки"} - - {id: 247, cat: Other, desc: "Компьютерные видеокурсы и уроки"} - - {id: 253, cat: Other, desc: " |- Компьютерные сети и безопасность"} - - {id: 252, cat: Other, desc: " |- ОС и серверные программы Microsoft"} - - {id: 251, cat: Other, desc: " |- 2D-графика"} - - {id: 250, cat: Other, desc: " |- 3D-графика, моделирование"} - - {id: 1810, cat: Other, desc: " |- Веб-дизайн, программирование"} - - {id: 249, cat: Other, desc: " |- Программирование"} - - {id: 248, cat: Other, desc: " |- Работа с видео"} - - {id: 1811, cat: Other, desc: " |- Прочие компьютерные видеокурсы и уроки"} - # Обучение иностранным языкам - # Teaching foreign languages - - {id: 324, cat: Other, desc: "Обучение иностранным языкам"} - - {id: 329, cat: Other, desc: " |- Аудиокниги на иностранных языках"} - - {id: 1289, cat: Other, desc: " |- Художественная литература на иностранных языках"} - - {id: 328, cat: Other, desc: " |- Английский язык"} - - {id: 327, cat: Other, desc: " |- Немецкий язык"} - - {id: 326, cat: Other, desc: " |- Французский язык"} - - {id: 325, cat: Other, desc: " |- Испанский язык"} - - {id: 1288, cat: Other, desc: " |- Японский язык"} - # Музыка - # Music - - {id: 471, cat: Audio, desc: "Зарубежный Рок, Панк, Альтернатива"} - - {id: 769, cat: Audio, desc: " |- Pop-Rock &Soft Rock (mp3)"} - - {id: 1160, cat: Audio, desc: " |- Gothic Rock &Dark Folk"} - - {id: 767, cat: Audio, desc: " |- Classic Rock &Hard Rock (mp3)"} - - {id: 473, cat: Audio, desc: " |- Punk, Hardcore / Reggae, SKA, Dub"} - - {id: 474, cat: Audio, desc: " |- Indie / Post-Punk / Britpop / Post-Rock"} - - {id: 475, cat: Audio, desc: " |- Alternative &Nu-Metal"} - - {id: 1159, cat: Audio, desc: " |- Progressive &Art-Rock"} - - {id: 728, cat: Audio, desc: " |- Emocore &Post-hardcore"} - - {id: 476, cat: Audio, desc: " |- Клипы"} - - {id: 478, cat: Audio, desc: "Зарубежный метал"} - - {id: 479, cat: Audio, desc: " |- Avant-garde, Experimetal Metal, Progressive"} - - {id: 480, cat: Audio, desc: " |- Black Metal"} - - {id: 770, cat: Audio, desc: " |- Symphonic Metal"} - - {id: 481, cat: Audio, desc: " |- Folk, Pagan, Viking"} - - {id: 482, cat: Audio, desc: " |- Doom, Sludge, Stoner"} - - {id: 483, cat: Audio, desc: " |- Gothic, Industrial Metal"} - - {id: 484, cat: Audio, desc: " |- Grind, Brutal Death"} - - {id: 485, cat: Audio, desc: " |- Death Metal"} - - {id: 486, cat: Audio, desc: " |- Heavy, Power Metal"} - - {id: 487, cat: Audio, desc: " |- Metalcore, Deathcore, Mathcore"} - - {id: 488, cat: Audio, desc: " |- Thrash, Speed, Groove"} - - {id: 489, cat: Audio, desc: " |- Клипы"} - - {id: 490, cat: Audio, desc: "Отечественный Рок, Панк, Метал, Альтернатива"} - - {id: 491, cat: Audio, desc: " |- Рок, Панк, Альтернатива"} - - {id: 492, cat: Audio, desc: " |- Метал"} - - {id: 494, cat: Audio, desc: " |- Клипы"} - - {id: 495, cat: Audio, desc: "Рэп, Хип - Хоп, R'n'B"} - - {id: 507, cat: Audio, desc: " |- R'n'B"} - - {id: 506, cat: Audio, desc: " |- Отечественный Rap / Hip-hop"} - - {id: 505, cat: Audio, desc: " |- Иностранный Rap / Hip-hop"} - - {id: 496, cat: Audio, desc: " |- Клипы"} - - {id: 669, cat: Audio, desc: "Электронная музыка"} - - {id: 789, cat: Audio, desc: " |- Chiptune"} - - {id: 702, cat: Audio, desc: " |- Trance"} - - {id: 700, cat: Audio, desc: " |- Goa Trance, Psy-Trance"} - - {id: 699, cat: Audio, desc: " |- PsyChill, Ambient Dub"} - - {id: 697, cat: Audio, desc: " |- House"} - - {id: 696, cat: Audio, desc: " |- Techno"} - - {id: 694, cat: Audio, desc: " |- Hardcore, Hardstyle, Jumpstyle"} - - {id: 692, cat: Audio, desc: " |- Drum &Bass"} - - {id: 691, cat: Audio, desc: " |- Jungle"} - - {id: 690, cat: Audio, desc: " |- Breakbeat"} - - {id: 689, cat: Audio, desc: " |- Dubstep"} - - {id: 688, cat: Audio, desc: " |- IDM"} - - {id: 687, cat: Audio, desc: " |- Chillout, Lounge, Downtempo"} - - {id: 684, cat: Audio, desc: " |- Trip-Hop"} - - {id: 683, cat: Audio, desc: " |- Nu Jazz, Acid Jazz, Future Jazz"} - - {id: 1031, cat: Audio, desc: " |- Electronic, Dance, Club"} - - {id: 679, cat: Audio, desc: " |- Traditional Electronic"} - - {id: 1158, cat: Audio, desc: " |- Radioshow, Podcast, Liveset, Mixes"} - - {id: 678, cat: Audio, desc: " |- Ambient"} - - {id: 677, cat: Audio, desc: " |- Experimental"} - - {id: 674, cat: Audio, desc: " |- EBM, Dark Electro, Aggrotech"} - - {id: 671, cat: Audio, desc: " |- Synthpop, New Wave, SpaceSynth"} - - {id: 788, cat: Audio, desc: " |- Uplifting Trance"} - - {id: 704, cat: Audio, desc: " |- Клипы"} - - {id: 508, cat: Audio, desc: "Классика и Инструментал"} - - {id: 511, cat: Audio, desc: " |- Инструменталы"} - - {id: 510, cat: Audio, desc: " |- Классика"} - - {id: 509, cat: Audio, desc: " |- Клипы"} - - {id: 512, cat: Audio, desc: "Джаз и Блюз"} - - {id: 708, cat: Audio, desc: " |- Отечественный Джаз"} - - {id: 709, cat: Audio, desc: " |- Отечественный Блюз"} - - {id: 710, cat: Audio, desc: " |- Зарубежный Джаз"} - - {id: 711, cat: Audio, desc: " |- Зарубежный Блюз"} - - {id: 513, cat: Audio, desc: " |- Клипы"} - - {id: 516, cat: Audio, desc: "Поп музыка"} - - {id: 1265, cat: Audio, desc: " |- Зарубежная Поп музыка"} - - {id: 518, cat: Audio, desc: " |- Отечественная Поп музыка"} - - {id: 738, cat: Audio, desc: " |- EuroDance(mp3)"} - - {id: 517, cat: Audio, desc: " |- Клипы"} - - {id: 519, cat: Audio, desc: "Шансон / Авторская песня"} - - {id: 522, cat: Audio, desc: " |- Шансон"} - - {id: 521, cat: Audio, desc: " |- Авторская песня"} - - {id: 520, cat: Audio, desc: " |- Клипы"} - - {id: 730, cat: Audio, desc: "Восточно-азиатская музыка"} - - {id: 735, cat: Audio, desc: " |- Рок"} - - {id: 734, cat: Audio, desc: " |- Метал"} - - {id: 733, cat: Audio, desc: " |- Рэп, Хип - Хоп, R'n'B"} - - {id: 732, cat: Audio, desc: " |- Поп музыка"} - - {id: 731, cat: Audio, desc: " |- Клипы"} - - {id: 523, cat: Audio, desc: "Музыка других жанров"} - - {id: 716, cat: Audio, desc: " |- Отечественная музыка других жанров"} - - {id: 714, cat: Audio, desc: " |- Зарубежная музыка других жанров"} - - {id: 715, cat: Audio, desc: " |- Советская эстрада, Ретро"} - - {id: 1148, cat: Audio, desc: " |- Неофициальные и внежанровые сборники"} - - {id: 713, cat: Audio, desc: " |- Православные песнопения"} - - {id: 712, cat: Audio, desc: " |- Сборники песен для детей"} - - {id: 524, cat: Audio, desc: " |- Клипы"} - - {id: 525, cat: Audio, desc: "Саундтреки и OST"} - - {id: 529, cat: Audio, desc: " |- Саундтреки к фильмам"} - - {id: 528, cat: Audio, desc: " |- Саундтреки к играм"} - - {id: 526, cat: Audio, desc: "NewAge &Meditative"} - - {id: 527, cat: Audio, desc: " |- Клипы"} - - {id: 668, cat: Audio, desc: "Industrial"} - - {id: 530, cat: Audio, desc: "Lossless музыка (без потери качества)"} - - {id: 542, cat: Audio, desc: " |- Зарубежный Рок, Панк, Метал, Альтернатива (lossless)"} - - {id: 541, cat: Audio, desc: " |- Отечественный Рок, Панк, Метал, Альтернатива (lossless)"} - - {id: 540, cat: Audio, desc: " |- Рэп, Хип - Хоп, R'n'B (lossless)"} - - {id: 539, cat: Audio, desc: " |- Электронная музыка (lossless)"} - - {id: 538, cat: Audio, desc: " |- Зарубежная поп музыка (lossless)"} - - {id: 763, cat: Audio, desc: " |- Отечественная поп музыка(lossless)"} - - {id: 739, cat: Audio, desc: " |- EuroDance (lossless)"} - - {id: 537, cat: Audio, desc: " |- Классика и Инструментал (lossless)"} - - {id: 705, cat: Audio, desc: " |- Джаз и Блюз (lossless)"} - - {id: 536, cat: Audio, desc: " |- Шансон / Авторская песня (lossless)"} - - {id: 535, cat: Audio, desc: " |- NewAge &Meditative (lossless)"} - - {id: 740, cat: Audio, desc: " |- Саундтреки к играм (lossless)"} - - {id: 741, cat: Audio, desc: " |- Саундтреки к фильмам (lossless)"} - - {id: 533, cat: Audio, desc: " |- DVD-Audio и многоканальный звук"} - - {id: 532, cat: Audio, desc: " |- Оцифровка аналоговых носителей"} - - {id: 543, cat: Audio, desc: "Видео концерты и документальные фильмы о музыке"} - - {id: 548, cat: Audio, desc: " |- Концерты (Зарубежный Rock, Metal, Punk, Alternative)"} - - {id: 547, cat: Audio, desc: " |- Концерты (Отечественный Рок, Метал, Панк, Альтернатива)"} - - {id: 546, cat: Audio, desc: " |- Концерты (Pop)"} - - {id: 545, cat: Audio, desc: " |- Концерты (Rap/R'n'B)"} - - {id: 544, cat: Audio, desc: " |- Концерты (Other)"} - - {id: 549, cat: Audio, desc: "Музыкальное DVD и HD видео"} - - {id: 551, cat: Audio, desc: " |- Концерты"} - - {id: 550, cat: Audio, desc: " |- Клипы"} - - {id: 553, cat: Audio, desc: "Караоке"} - # Книги, Журналы, Ноты - # Books, Magazines, Sheet music - - {id: 1450, cat: Books, desc: "Художественная литература"} - - {id: 1209, cat: Books, desc: " |- Новинки"} - - {id: 1215, cat: Books, desc: " |- Боевики/Детективы/Триллеры"} - - {id: 1218, cat: Books, desc: " |- Приключения"} - - {id: 1219, cat: Books, desc: " |- Романы"} - - {id: 1212, cat: Books, desc: " |- Отечественная фантастика / фэнтези / мистика / ужасы"} - - {id: 1211, cat: Books, desc: " |- Зарубежная фантастика / фэнтези / мистика / ужасы"} - - {id: 1210, cat: Books, desc: " |- Классическая литература и современная проза"} - - {id: 334, cat: Books, desc: " |- Детские книги и журналы"} - - {id: 1316, cat: Books, desc: " |- Комедийные произведения, юмор, сатира"} - - {id: 338, cat: Books, desc: " |- Разное"} - - {id: 1449, cat: Books, desc: "Военная литература"} - - {id: 1223, cat: Books, desc: " |- Военная история"} - - {id: 1451, cat: Books, desc: " |- Военная техника"} - - {id: 1452, cat: Books, desc: " |- Стрелковое оружие"} - - {id: 1453, cat: Books, desc: " |- Холодное оружие"} - - {id: 1454, cat: Books, desc: " |- Боевые искусства"} - - {id: 1781, cat: Books, desc: " |- Энциклопедии, справочники"} - - {id: 1448, cat: Books, desc: "Журналы"} - - {id: 337, cat: Books, desc: " |- Авто-мото журналы"} - - {id: 1455, cat: Books, desc: " |- Строительство и дизайн"} - - {id: 1456, cat: Books, desc: " |- Политика и бизнес"} - - {id: 1457, cat: Books, desc: " |- Женские журналы"} - - {id: 1774, cat: Books, desc: " |- Медицина и ЗОЖ"} - - {id: 1773, cat: Books, desc: " |- Аудио-видео журналы"} - - {id: 1458, cat: Books, desc: " |- Компьютерные журналы"} - - {id: 1459, cat: Books, desc: " |- Кулинария"} - - {id: 1772, cat: Books, desc: " |- Сад, огород, дача"} - - {id: 1460, cat: Books, desc: " |- Мужские журналы"} - - {id: 1461, cat: Books, desc: " |- Научно-популярные журналы"} - - {id: 1776, cat: Books, desc: " |- Домоводство / Своими руками"} - - {id: 1462, cat: Books, desc: " |- Эротические журналы"} - - {id: 1463, cat: Books, desc: " |- Комиксы"} - - {id: 1775, cat: Books, desc: " |- Хобби (Разное)"} - - {id: 1447, cat: Books, desc: "Компьютерная литература"} - - {id: 1464, cat: Books, desc: " |- Информатика"} - - {id: 1465, cat: Books, desc: " |- Веб-дизайн"} - - {id: 1466, cat: Books, desc: " |- Базы данных"} - - {id: 1467, cat: Books, desc: " |- Интернет, сети"} - - {id: 1468, cat: Books, desc: " |- Программирование и алгоритмы"} - - {id: 1469, cat: Books, desc: " |- Графика, обработка видео"} - - {id: 1470, cat: Books, desc: " |- Операционные системы"} - - {id: 1471, cat: Books, desc: " |- Информационная безопасность"} - - {id: 1472, cat: Books, desc: " |- Литература по компьютерным играм"} - - {id: 1473, cat: Books, desc: " |- Разное"} - - {id: 1782, cat: Books, desc: " |- Энциклопедии, справочники"} - - {id: 1446, cat: Books, desc: "Авто/мото литература"} - - {id: 1783, cat: Books, desc: " |- Энциклопедии, справочники"} - - {id: 1445, cat: Books, desc: "Научно-образовательная и справочная литература"} - - {id: 1780, cat: Books, desc: " |- Авиация / Космонавтика"} - - {id: 1437, cat: Books, desc: " |- Гуманитарные науки"} - - {id: 341, cat: Books, desc: " |- Точные и естественные науки"} - - {id: 1474, cat: Books, desc: " |- Техническая литература"} - - {id: 1221, cat: Books, desc: " |- В помощь школьникам и студентам"} - - {id: 1475, cat: Books, desc: " |- Обучение иностранным языкам"} - - {id: 815, cat: Books, desc: " |- Психология"} - - {id: 1476, cat: Books, desc: " |- Юридическая литература и право"} - - {id: 1477, cat: Books, desc: " |- Энциклопедии, словари, справочники"} - - {id: 1478, cat: Books, desc: " |- Нормативные документы и законодательные акты"} - - {id: 1444, cat: Books, desc: "Медицина, здоровье, спорт"} - - {id: 1479, cat: Books, desc: " |- Справочники, энциклопедии"} - - {id: 1480, cat: Books, desc: " |- Сексология"} - - {id: 1481, cat: Books, desc: " |- Разное"} - - {id: 1443, cat: Books, desc: "Увлечения и хобби"} - - {id: 761, cat: Books, desc: " |- Охота и Рыбалка"} - - {id: 1357, cat: Books, desc: " |- Сад, огород"} - - {id: 1224, cat: Books, desc: " |- Кулинария"} - - {id: 817, cat: Books, desc: " |- Строительство, ремонт, интерьер"} - - {id: 1482, cat: Books, desc: " |- Декоративно-прикладное искусство"} - - {id: 1483, cat: Books, desc: " |- Сделай сам"} - - {id: 1484, cat: Books, desc: " |- Фотоискусство"} - - {id: 1485, cat: Books, desc: " |- Прочие хобби"} - - {id: 1784, cat: Books, desc: " |- Энциклопедии, справочники"} - - {id: 1442, cat: Books, desc: "История, политология, публицистика"} - - {id: 1486, cat: Books, desc: " |- Античная литература. Мифы, легенды"} - - {id: 1487, cat: Books, desc: " |- Биография и мемуары"} - - {id: 1488, cat: Books, desc: " |- Прочее"} - - {id: 1785, cat: Books, desc: " |- Энциклопедии, справочники"} - - {id: 1441, cat: Books, desc: "Библиотеки и мультимедийные издания"} - - {id: 1489, cat: Books, desc: " |- Коллекции книг, собрания сочинений"} - - {id: 1490, cat: Books, desc: " |- Мультимедийные и интерактивные издания"} - - {id: 1440, cat: Books, desc: "Ноты и Теория музыки"} - - {id: 348, cat: Books, desc: " |- Ноты"} - # Аудиокниги - # Audiobooks - - {id: 343, cat: Audio/Audiobook, desc: "Аудиокниги"} - - {id: 820, cat: Audio/Audiobook, desc: " |- История"} - - {id: 819, cat: Audio/Audiobook, desc: " |- Юмор"} - - {id: 818, cat: Audio/Audiobook, desc: " |- Религия"} - - {id: 346, cat: Audio/Audiobook, desc: " |- Детская литература"} - - {id: 345, cat: Audio/Audiobook, desc: " |- Фантастика"} - - {id: 1142, cat: Audio/Audiobook, desc: " |- Классика"} - - {id: 1438, cat: Audio/Audiobook, desc: " |- Поэзия"} - - {id: 821, cat: Audio/Audiobook, desc: " |- Психология"} - - {id: 344, cat: Audio/Audiobook, desc: " |- Художественная литература"} - - {id: 1908, cat: Audio/Audiobook, desc: " |- Прочая литература"} - - {id: 1140, cat: Audio/Audiobook, desc: "Аудиоспектакли"} - # Мобильные устройства - # Mobile devices - - {id: 1173, cat: PC/Mobile-Other, desc: "F.A.Q. по разделу / Прочее"} - - {id: 1166, cat: PC/Mobile-Other, desc: "Windows Phone"} - - {id: 1176, cat: PC/Mobile-Other, desc: " |- Игры"} - - {id: 1175, cat: PC/Mobile-Other, desc: " |- Программы"} - - {id: 1174, cat: PC/Mobile-Other, desc: " |- Прошивки"} - - {id: 1202, cat: PC/Mobile-Other, desc: " |- Разное"} - - {id: 1167, cat: PC/Mobile-Other, desc: "Android"} - - {id: 1179, cat: PC/Mobile-Other, desc: " |- Игры"} - - {id: 1178, cat: PC/Mobile-Other, desc: " |- Программы"} - - {id: 1177, cat: PC/Mobile-Other, desc: " |- Прошивки"} - - {id: 1203, cat: PC/Mobile-Other, desc: " |- Разное"} - - {id: 1168, cat: PC/Mobile-Other, desc: "iOS"} - - {id: 1182, cat: PC/Mobile-Other, desc: " |- Игры"} - - {id: 1181, cat: PC/Mobile-Other, desc: " |- Программы"} - - {id: 1180, cat: PC/Mobile-Other, desc: " |- Прошивки"} - - {id: 1205, cat: PC/Mobile-Other, desc: " |- Разное"} - - {id: 1170, cat: PC/Mobile-Other, desc: "Другие ОС"} - - {id: 1188, cat: PC/Mobile-Other, desc: " |- Игры"} - - {id: 1187, cat: PC/Mobile-Other, desc: " |- Программы"} - - {id: 1186, cat: PC/Mobile-Other, desc: " |- Прошивки"} - - {id: 1206, cat: PC/Mobile-Other, desc: " |- Разное"} - - {id: 1172, cat: PC/Mobile-Other, desc: "Программы для PC"} - # Программное обеспечение - # Software - - {id: 349, cat: PC, desc: "Mac OS. Программное обеспечение под них"} - - {id: 352, cat: PC, desc: " |- Mac OS (для Apple Macintosh)"} - - {id: 351, cat: PC, desc: " |- Mac OS (для РС-Hackintosh)"} - - {id: 350, cat: PC, desc: " |- Системные программы для Mac OS"} - - {id: 353, cat: PC, desc: "Unix, Unix-подобные (*nix) и другие OS. Программное обеспече.."} - - {id: 356, cat: PC, desc: " |- Операционные системы (Unix и Unix-подобные)"} - - {id: 355, cat: PC, desc: " |- Программное обеспечение для Unix и Unix-подобных OS"} - - {id: 354, cat: PC, desc: " |- Другие OS и программное обеспечение под них"} - - {id: 1427, cat: PC, desc: "Microsoft Windows OS"} - - {id: 1433, cat: PC, desc: " |- Настольные OS, выпущенные до 2001 года (Microsoft Windows OS.."} - - {id: 1432, cat: PC, desc: " |- Windows XP"} - - {id: 1431, cat: PC, desc: " |- Windows Vista"} - - {id: 1430, cat: PC, desc: " |- Windows 7"} - - {id: 1429, cat: PC, desc: " |- Windows 8"} - - {id: 1906, cat: PC, desc: " |- Windows 10"} - - {id: 2054, cat: PC, desc: " |- Windows 11"} - - {id: 1428, cat: PC, desc: " |- Серверные (Microsoft Windows OS)"} - - {id: 1910, cat: PC, desc: " |- Мультисборки Windows OS"} - - {id: 363, cat: PC, desc: "Системные программы"} - - {id: 367, cat: PC, desc: " |- Настройка и обслуживание. Диагностика"} - - {id: 366, cat: PC, desc: " |- Архиваторы и файловые менеджеры"} - - {id: 365, cat: PC, desc: " |- Работа с носителями информации"} - - {id: 460, cat: PC, desc: " |- Драйвера"} - - {id: 364, cat: PC, desc: " |- Антивирусы, Файерволы и защита информации"} - - {id: 1070, cat: PC, desc: " |- Разное"} - - {id: 368, cat: PC, desc: "Пользовательские программы"} - - {id: 371, cat: PC, desc: " |- Системы для офиса, бизнеса и научной работы"} - - {id: 370, cat: PC, desc: " |- Аудио- и видео-, CD- проигрыватели и каталогизаторы"} - - {id: 369, cat: PC, desc: " |- Обучающие программы, справочники, энциклопедии"} - - {id: 1071, cat: PC, desc: " |- Программы для Интернет и сетей"} - - {id: 1072, cat: PC, desc: " |- Программы для изменения интерфейса Windows"} - - {id: 1073, cat: PC, desc: " |- Разное"} - - {id: 372, cat: PC, desc: "Веб-разработка и Программирование"} - - {id: 376, cat: PC, desc: " |- Текстовые редакторы с подсветкой"} - - {id: 375, cat: PC, desc: " |- Среды программирования, компиляторы и вспомогательные програ.."} - - {id: 374, cat: PC, desc: " |- Компоненты для сред программирования"} - - {id: 373, cat: PC, desc: " |- Системы управления базами данных (СУБД)"} - - {id: 762, cat: PC, desc: " |- Веб-скрипты"} - - {id: 377, cat: PC, desc: "Создание и редактирование мультимедиа и 3D контента"} - - {id: 381, cat: PC, desc: " |- Программные комплекты"} - - {id: 380, cat: PC, desc: " |- Графические редакторы"} - - {id: 379, cat: PC, desc: " |- Редакторы видео"} - - {id: 378, cat: PC, desc: " |- Работа со звуком"} - - {id: 1074, cat: PC, desc: " |- Конвертеры"} - - {id: 1075, cat: PC, desc: " |- Разное"} - - {id: 1911, cat: PC, desc: "Прочее ПО"} - - {id: 1916, cat: PC, desc: " |- WPI"} - - {id: 1915, cat: PC, desc: " |- САПР"} - - {id: 1914, cat: PC, desc: " |- ГИС"} - - {id: 1913, cat: PC, desc: " |- LiveCD/DVD/Flash/USB"} - - {id: 385, cat: PC, desc: " |- 3D графика"} - - {id: 1912, cat: PC, desc: " |- Прочее"} - # Wallpapers / Обои / Рисунки / Картинки - # Wallpapers / Wallpaper / Drawings / Pictures - - {id: 1375, cat: Other, desc: "Обои"} - - {id: 1377, cat: Other, desc: "Иконки"} - - {id: 1376, cat: Other, desc: "Аватары"} - - {id: 387, cat: Other, desc: "Растровые и Векторные Клипарты"} - - {id: 386, cat: Other, desc: "Заготовки, виньетки, рамки"} - - {id: 388, cat: Other, desc: "Разное"} - # Архив - # Archive - - {id: 1297, cat: Other, desc: "Архив"} - - {id: 1299, cat: Other, desc: " |- Архив фильмов | мультфильмов"} - - {id: 1300, cat: Other, desc: " |- Архив сериалов | мультсериалов"} - - {id: 1302, cat: Other, desc: " |- Архив Anime"} - - {id: 1303, cat: Other, desc: " |- Архив музыки"} - - {id: 1304, cat: Other, desc: " |- Архив игр для PC"} - - {id: 1305, cat: Other, desc: " |- Архив игр для консолей"} - - {id: 1306, cat: Other, desc: " |- Архив для мобильных устройств"} - - {id: 1307, cat: Other, desc: " |- Архив для программного обеспечения"} - - {id: 1308, cat: Other, desc: " |- Архив (для раздач других категорий)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: forum/index.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - - path: forum/tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 0 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - prev_snotd: 0 - prev_not_checked: 0 - prev_closed: 0 - prev_d: 0 - prev_not_perfect: 0 - prev_part_perfect: 0 - prev_fishily: 0 - prev_copy: 0 - prev_temp: 0 - prev_pogl: 0 - prev_mod: 0 - prev_gold: 0 - prev_silver: 0 - # sort by 1=registered, 2=topicTitle, 4=completed, 10=seed, 11=leech, 12=up, 13=down, 5=replies, 6=views, 7=size, 8=lastPost, 9=seedLastSeen - o: "{{ .Config.sort }}" - # 1=asc, 2=desc - s: "{{ .Config.type }}" - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # show speed - ds: 0 - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - # group releases -1=not selected - srg: -1 - nm: "{{ .Keywords }}" - # search by partial word - allw: 0 - gold: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - headers: - Cookie: ["godbayadblock=godbayadblock"] - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: tr[id^="tor_"]:has(a[href^="./download.php?id="]) - - fields: - category: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a[href^="./viewtopic.php?t="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="./viewtopic.php?t="] - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - size: - selector: td:nth-child(7) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(10) - date: - # unix - selector: td:last-child > u - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - img[src="images/tor_silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="./viewtopic.php?t="] -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rutor.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rutor.yml deleted file mode 100644 index 136fe5d1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rutor.yml +++ /dev/null @@ -1,206 +0,0 @@ ---- -id: rutor -name: RuTor -description: "RuTor is a RUSSIAN Public site for MOVIES / TV / GENERAL" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://rutor.info/ - - https://rutor.is/ - - http://6tor.org/ # IPv6-only -legacylinks: - - https://rutor.uk-unblock.xyz/ - - https://rutor.ind-unblock.xyz/ - - https://rutor.unblocked.bar/ - - https://rutor.proxyportal.pw/ - - https://rutor.uk-unblock.pro/ - - https://rutor.root.yt/ - - https://rutor.unblocked.rest/ - - https://rutor.unblocked.monster/ - - https://rutor.mrunblock.bond/ # for magnet only - - https://rutor.nocensor.cloud/ - - http://new-rutor.org/ # Oops. Something went wrong, try reloading the page - - http://rutor.info/ - - http://rutor.is/ - -caps: - # unfortunately RuTor does not display categories anywhere in its search results page :-( - # albeit you can select a single category in its search options - # so I've opted not to support categories - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site (Applies only to Search with Keywords) - default: 0 - options: - 0: "created desc" - 1: "created asc" - 2: "seeders desc" - 3: "seeders asc" - 6: "size desc" - 7: "size asc" - 8: "title desc" - 9: "title asc" - - name: info_category_8000 - type: info_category_8000 - -search: - paths: - # http://rutor.info/search/0/0/010/0/tt10288566 # search title and descr for imdb - # http://rutor.info/search/0/0/100/0/gotham%2004x01 # search title for query - # http://rutor.info/ # search keywordless - - path: "{{ if .Query.IMDBID }}search/0/0/010/{{ .Config.sort }}/{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if .Keywords }}search/0/0/100/{{ .Config.sort }}/{{ .Keywords }}/{{ else }}{{ end }}{{ if or .Keywords .Query.IMDBID }}{{ else }}/{{ end }}" - - rows: - selector: tr:has(td:has(a[href^="magnet:?xt="])) - - fields: - category: - text: 3 - title: - selector: td:nth-of-type(2) a[href^="/torrent/"] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?)\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?)\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(\\d+(?:-\\d+)?)[хx](\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ери[ияй]|Эпизод|Выпуски?)\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:[CС]ери[ияй]|Эпизод|Выпуски?)[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:[CС]ери[ияй]|Эпизод|Выпуски?)", "E$1"] - - name: re_replace - args: ["(?i)\\bFiles-х\\b", "Files-x"] - - name: re_replace - args: ["(?i)\\s\\|\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(?i)\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: td:nth-of-type(2) a[href^="/torrent/"] - attribute: href - download: - selector: a.downgif - attribute: href - infohash: - selector: a[href^="magnet:?xt="] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - date: - # 27 Окт 17 - selector: td:nth-of-type(1) - filters: - # replace white spaces - - name: re_replace - args: ["[\\s]+", " "] - # replace month abbreviations - - name: replace - args: ["Янв", "Jan"] - - name: replace - args: ["Фев", "Feb"] - - name: replace - args: ["Мар", "Mar"] - - name: replace - args: ["Апр", "Apr"] - - name: replace - args: ["Май", "May"] - - name: replace - args: ["Июн", "Jun"] - - name: replace - args: ["Июл", "Jul"] - - name: replace - args: ["Авг", "Aug"] - - name: replace - args: ["Сен", "Sep"] - - name: replace - args: ["Окт", "Oct"] - - name: replace - args: ["Ноя", "Nov"] - - name: replace - args: ["Дек", "Dec"] - - name: append - args: " 00:00:00 +03:00" # MSK - - name: dateparse - args: "dd MMM yy HH:mm:ss zzz" - # some results don't have comments which throws off td count. So we go fishing for the following fields - size: - optional: true - selector: td:contains(\00a0TB), td:contains(\00a0GB), td:contains(\00a0MB), td:contains(\00a0kB), td:contains(\00a0B) - seeders: - selector: td span.green - leechers: - selector: td span.red - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: td:nth-of-type(2) a[href^="/torrent/"] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rutracker-ru.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rutracker-ru.yml deleted file mode 100644 index d8fc2eee..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/rutracker-ru.yml +++ /dev/null @@ -1,548 +0,0 @@ ---- -id: rutracker-ru -name: RuTracker.RU -description: "RuTracker.RU is a RUSSIAN Public Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: public -encoding: UTF-8 -links: - - http://rutracker.ru/ # site does not support https - -caps: - categorymappings: - # Кинематограф movies - - {id: 1748, cat: Movies, desc: "Кинематограф Зарубежные фильмы 2025 г."} - - {id: 1757, cat: Movies/HD, desc: "Кинематограф Зарубежные фильмы 2025 (HD Video)"} - - {id: 1749, cat: Movies, desc: "Кинематограф Российские фильмы 2025 г."} - - {id: 1758, cat: Movies/HD, desc: "Кинематограф Российские фильмы 2025 (HD Video)"} - - {id: 1735, cat: Movies, desc: "Кинематограф Фильмы 2024 года"} - - {id: 1736, cat: Movies, desc: "Кинематограф Зарубежные фильмы 2024"} - - {id: 1737, cat: Movies/HD, desc: "Кинематограф Зарубежные фильмы 2024 (HD Video)"} - - {id: 1738, cat: Movies, desc: "Кинематограф Российские фильмы 2024"} - - {id: 1739, cat: Movies/HD, desc: "Кинематограф Российские фильмы 2024 (HD Video)"} - - {id: 1695, cat: Movies, desc: "Кинематограф Фильмы 2023 года"} - - {id: 1697, cat: Movies, desc: "Кинематограф Зарубежные фильмы (2023)"} - - {id: 1696, cat: Movies, desc: "Кинематограф Российские фильмы (2023)"} - - {id: 1698, cat: Movies/HD, desc: "Кинематограф Зарубежные фильмы в 720p (2023)"} - - {id: 1699, cat: Movies/HD, desc: "Кинематограф Российские фильмы в 720p (2023)"} - - {id: 1701, cat: Movies/HD, desc: "Кинематограф Зарубежные фильмы в 1080p (2023)"} - - {id: 1702, cat: Movies/HD, desc: "Кинематограф Российские фильмы в 1080р (2023)"} - - {id: 1671, cat: Movies, desc: "Кинематограф Фильмы 2022 года"} - - {id: 1677, cat: Movies, desc: "Кинематограф Зарубежные фильмы (2022)"} - - {id: 1676, cat: Movies, desc: "Кинематограф Российские фильмы (2022)"} - - {id: 1675, cat: Movies/HD, desc: "Кинематограф Зарубежные фильмы в 720p (2022)"} - - {id: 1674, cat: Movies/HD, desc: "Кинематограф Российские фильмы в 720p (2022)"} - - {id: 1673, cat: Movies/HD, desc: "Кинематограф Зарубежные фильмы в 1080p (2022)"} - - {id: 1672, cat: Movies/HD, desc: "Кинематограф Российские фильмы в 1080р (2022)"} - - {id: 1656, cat: Movies, desc: "Фильмы 2021 года"} - - {id: 1662, cat: Movies, desc: " |- Зарубежные фильмы (2021)"} - - {id: 1661, cat: Movies, desc: " |- Российские фильмы (2021)"} - - {id: 1660, cat: Movies/HD, desc: " |- Зарубежные фильмы в 720p (2021)"} - - {id: 1659, cat: Movies/HD, desc: " |- Российские фильмы в 720p (2021)"} - - {id: 1658, cat: Movies/HD, desc: " |- Зарубежные фильмы в 1080p (2021)"} - - {id: 1657, cat: Movies/HD, desc: " |- Российские фильмы в 1080р (2021)"} - - {id: 1730, cat: Movies, desc: " |- Зарубежные фильмы"} - - {id: 1731, cat: Movies/HD, desc: " |- Зарубежные фильмы (HDvideo)"} - - {id: 1732, cat: Movies, desc: " |- Российские фильмы"} - - {id: 1733, cat: Movies/HD, desc: " |- Российские фильмы (HDvideo)"} - - {id: 1725, cat: Movies, desc: " |- Зарубежные фильмы"} - - {id: 1726, cat: Movies/HD, desc: " |- Зарубежные фильмы (HDvideo)"} - - {id: 1727, cat: Movies, desc: " |- Российские фильмы"} - - {id: 1728, cat: Movies/HD, desc: " |- Российские фильмы (HDvideo)"} - - {id: 1719, cat: Movies, desc: " |- Зарубежные фильмы"} - - {id: 1720, cat: Movies/HD, desc: " |- Зарубежные фильмы (HDvideo)"} - - {id: 1721, cat: Movies, desc: " |- Российские фильмы"} - - {id: 1722, cat: Movies/HD, desc: " |- Российские фильмы (HDvideo)"} - - {id: 1715, cat: Movies, desc: " |- Зарубежные фильмы до 1990 г."} - - {id: 1734, cat: Movies, desc: " |- Классика зарубежного кино"} - - {id: 1716, cat: Movies, desc: " |- Советское кино"} - - {id: 820, cat: Movies/HD, desc: "Зарубежные фильмы"} - - {id: 840, cat: Movies/DVD, desc: " |- DVD5 , DVD9"} - - {id: 841, cat: Movies/3D, desc: " |- Фильмы в 3D формате"} - - {id: 825, cat: Movies, desc: " |- Азиатское кино"} - - {id: 830, cat: Movies, desc: " |- Индийское кино"} - - {id: 1317, cat: Movies, desc: " |- Детские фильмы, сказки, сериалы"} - - {id: 838, cat: Movies, desc: " |- Короткометражные фильмы"} - - {id: 845, cat: Movies, desc: " |- В оригинале"} - - {id: 1560, cat: Movies, desc: " |- Фильмы с переводом UKR"} - - {id: 798, cat: Movies/HD, desc: "Российские фильмы"} - - {id: 802, cat: Movies/DVD, desc: " |- DVD5 , DVD9"} - - {id: 801, cat: Movies/3D, desc: " |- Фильмы в 3D формате"} - - {id: 1318, cat: Movies, desc: " |- Детские фильмы, сказки, сериалы"} - # Сериалы TV - - {id: 1751, cat: TV, desc: " |- Зарубежные сериалы 2025 r."} - - {id: 1752, cat: TV/HD, desc: " |- Зарубежные сериалы 2025 (HD Video)"} - - {id: 1754, cat: TV, desc: " |- Отечественные сериалы 2025 r."} - - {id: 1756, cat: TV/HD, desc: " |- Отечественные сериалы 2025 (HD Video)"} - - {id: 1742, cat: TV, desc: " |- Зарубежные сериалы 2024"} - - {id: 1743, cat: TV/HD, desc: " |- Зарубежные сериалы 2024 (HD Video)"} - - {id: 1744, cat: TV, desc: " |- Отечественные сериалы 2024"} - - {id: 1745, cat: TV/HD, desc: " |- Отечественные сериалы 2024 (HD Video)"} - - {id: 1708, cat: TV, desc: " |- Зарубежные сериалы 2023"} - - {id: 1710, cat: TV/HD, desc: " |- Зарубежные сериалы 2023 (720p, 1080p)"} - - {id: 1709, cat: TV, desc: " |- Отечественные сериалы 2023"} - - {id: 1711, cat: TV/HD, desc: " |- Отечественные сериалы 2023 (720p, 1080p)"} - - {id: 1705, cat: TV/WEB-DL, desc: " |- WEBRip, WEB-DLRip, SATRip"} - - {id: 1086, cat: TV/HD, desc: " |- Зарубежные сериалы (HD Video)"} - - {id: 1085, cat: TV/HD, desc: " |- Зарубежные сериалы (Full HD)"} - - {id: 1551, cat: TV/UHD, desc: " |- Зарубежные сериалы 4K"} - - {id: 1087, cat: TV/SD, desc: " |- Сериалы на DVD"} - - {id: 1703, cat: TV/WEB-DL, desc: " |- WEBRip, WEB-DLRip, SATRip"} - - {id: 1083, cat: TV/HD, desc: " |- Отечественные сериалы (HD Video)"} - - {id: 1082, cat: TV/HD, desc: " |- Отечественные сериалы (Full HD)"} - - {id: 1084, cat: TV/SD, desc: " |- Сериалы на DVD"} - # Мультипликация Animation - - {id: 125, cat: TV, desc: "Мультфильмы"} - - {id: 1353, cat: TV/HD, desc: " |- Мультфильмы 1080р"} - - {id: 1355, cat: TV/HD, desc: " |- Мультфильмы 720p"} - - {id: 1352, cat: TV/SD, desc: " |- Мультфильмы на DVD"} - - {id: 1343, cat: TV/Anime, desc: " |- Аниме"} - - {id: 1025, cat: TV/Anime, desc: " |- Мультфильмы в 3D формате"} - - {id: 8, cat: TV, desc: "Мультсериалы"} - - {id: 1347, cat: TV/HD, desc: " |- Мультсериалы 1080р"} - - {id: 1348, cat: TV/HD, desc: " |- Мультсериалы 720p"} - - {id: 1349, cat: TV/SD, desc: " |- Мультсериалы на DVD"} - # Документалистика, развлекательные телепередачи, спорт Docs - - {id: 12, cat: TV, desc: "Развлекательные телепередачи"} - - {id: 13, cat: TV/Sport, desc: "Спорт"} - # Игры Games - - {id: 60, cat: PC/Games, desc: "Игры для PC"} - - {id: 73, cat: Console, desc: " |- Горячие новинки 2021 года (только AAA проекты)"} - - {id: 61, cat: Console, desc: " |- Онлайн Игры"} - - {id: 1234, cat: Console, desc: " |- Симуляторы"} - - {id: 84, cat: Console, desc: " |- Action"} - - {id: 82, cat: Console, desc: " |- Гонки"} - - {id: 85, cat: Console, desc: " |- RPG"} - - {id: 78, cat: Console, desc: " |- Стратегии"} - - {id: 77, cat: Console, desc: " |- Приключения и квесты"} - - {id: 76, cat: Console, desc: " |- Аркады"} - - {id: 1538, cat: Console, desc: "Игры для консолей"} - - {id: 1539, cat: Console/PSP, desc: " |- PlayStation"} - - {id: 878, cat: Console/XBox, desc: " |- XBox"} - # Книги, Журналы, Аудиокниги Books - - {id: 726, cat: Books, desc: "Художественная литература, журналы, газеты"} - - {id: 728, cat: Books, desc: " |- Журналы и газеты"} - - {id: 761, cat: Books, desc: " |- Фантастика, фентези, мистика"} - - {id: 760, cat: Books, desc: " |- Детективы, боевики, триллеры"} - - {id: 757, cat: Books, desc: " |- Классика мировой литературы"} - - {id: 1314, cat: Books, desc: " |- Детская литература"} - - {id: 722, cat: Books, desc: "Научная, техническая и учебная литература, хобби"} - - {id: 727, cat: Books, desc: " |- Увлечения, хобби"} - - {id: 1021, cat: Books, desc: " |- Инструкции, Схемы, Технические и сервисные документации"} - - {id: 1020, cat: Books, desc: " |- Сварка, Пайка"} - - {id: 730, cat: Audio/Audiobook, desc: "Аудиокниги"} - - {id: 776, cat: Audio/Audiobook, desc: " |- Фантастика ♪ Фэнтези ♪ Ужасы ♪ Мистика (Аудио)"} - - {id: 777, cat: Audio/Audiobook, desc: " |- Детективы ♪ приключения ♪ триллеры ♪ боевики (Аудио)"} - - {id: 1156, cat: Audio/Audiobook, desc: " |- Историческая литература ♪ биографии ♪ мемуары (Аудио)"} - - {id: 1158, cat: Audio/Audiobook, desc: " |- Зарубежная литература (Аудио)"} - - {id: 1233, cat: Audio/Audiobook, desc: " |- Русская литература (Аудио)"} - - {id: 1159, cat: Audio/Audiobook, desc: " |- Современные любовные романы (Аудио)"} - - {id: 1315, cat: Audio/Audiobook, desc: " |- Детская литература (Аудио)"} - - {id: 1223, cat: Audio/Audiobook, desc: " |- Учебная и научно-популярная литература (Аудио)"} - # Музыка Music - - {id: 1635, cat: Audio/MP3, desc: "Зарубежная музыка"} - - {id: 1637, cat: Audio/MP3, desc: " |- Зарубежные сборники mp3"} - - {id: 1643, cat: Audio/MP3, desc: " |- Зарубежная поп музыка (mp3)"} - - {id: 1636, cat: Audio/MP3, desc: " |- Сборники музыки 50/50 mp3"} - - {id: 1639, cat: Audio/MP3, desc: " |- Сборники клубной музыки (Club,Dance)"} - - {id: 1640, cat: Audio/Lossless, desc: " |- Зарубежный шансон (mp3,Flac)"} - - {id: 1177, cat: Audio/MP3, desc: "Отечественная музыка."} - - {id: 1642, cat: Audio/MP3, desc: " |- Российская попса (альбомы, синглы mp3)"} - - {id: 1427, cat: Audio/MP3, desc: " |- Сборники Российской поп музыки (mp3)"} - - {id: 1641, cat: Audio/MP3, desc: " |- Шансон и авторская песня (mp3)"} - - {id: 1561, cat: Audio/MP3, desc: " |- Сборники Русского шансона (mp3)"} - - {id: 1598, cat: Audio/MP3, desc: "Отечественный Rock, Metal"} - - {id: 1599, cat: Audio/MP3, desc: " |- Отечественный Rock (mp3)"} - - {id: 1600, cat: Audio/MP3, desc: " |- Отечественный Metal (mp3)"} - - {id: 1601, cat: Audio/MP3, desc: " |- Сборники Rock, Metal (mp3)"} - - {id: 1200, cat: Audio/MP3, desc: " |- Classic Rock & Hard Rock"} - - {id: 1552, cat: Audio/MP3, desc: " |- Progressive & Art-Rock"} - - {id: 1565, cat: Audio/MP3, desc: " |- Folk-Rock, Folk-Punk, Songwriter"} - - {id: 1554, cat: Audio/MP3, desc: " |- Melodic Rock, Arena rock"} - - {id: 1553, cat: Audio/MP3, desc: " |- Pop-Rock & Soft Rock"} - - {id: 1567, cat: Audio/MP3, desc: " |- Instrumental Guitar Rock (Виртуозы гитары)"} - - {id: 1566, cat: Audio/MP3, desc: " |- Rockabilly, Rock'n'Roll"} - - {id: 1713, cat: Audio/MP3, desc: " |- Blues-Rock, Country, Indie-Rock"} - - {id: 1556, cat: Audio/MP3, desc: " |- Сборники зарубежного рока"} - - {id: 1588, cat: Audio/MP3, desc: " |- Avant-garde, Experimental Metal"} - - {id: 1580, cat: Audio/MP3, desc: " |- Black Metal"} - - {id: 1581, cat: Audio/MP3, desc: " |- Death, Doom"} - - {id: 1582, cat: Audio/MP3, desc: " |- Folk, Pagan, Viking"} - - {id: 1583, cat: Audio/MP3, desc: " |- Gothic Metal"} - - {id: 1584, cat: Audio/MP3, desc: " |- Grind, Brutal Death"} - - {id: 1585, cat: Audio/MP3, desc: " |- Heavy, Power, Progressive"} - - {id: 1586, cat: Audio/MP3, desc: " |- Sludge, Stoner, Post-Metal"} - - {id: 1587, cat: Audio/MP3, desc: " |- Thrash, Speed"} - - {id: 1602, cat: Audio/MP3, desc: " |- Сборники Metal"} - - {id: 1590, cat: Audio/MP3, desc: " |- Alternative & Nu-metal"} - - {id: 1591, cat: Audio/MP3, desc: " |- Punk"} - - {id: 1592, cat: Audio/MP3, desc: " |- Hardcore"} - - {id: 1593, cat: Audio/MP3, desc: " |- Indie, Post-Rock & Post-Punk"} - - {id: 1594, cat: Audio/MP3, desc: " |- Industrial & Post-industrial"} - - {id: 1595, cat: Audio/MP3, desc: " |- Emocore, Post-hardcore, Metalcore, Deathcore"} - - {id: 1596, cat: Audio/MP3, desc: " |- Gothic Rock & Dark Folk"} - - {id: 1597, cat: Audio/MP3, desc: " |- Avant-garde, Experimental Rock"} - - {id: 1626, cat: Audio/MP3, desc: " |- Зарубежный джаз (mp3)"} - - {id: 1627, cat: Audio/MP3, desc: " |- Зарубежный блюз (mp3)"} - - {id: 1628, cat: Audio/MP3, desc: " |- Отечественный джаз и блюз (mp3)"} - - {id: 1610, cat: Audio/MP3, desc: " |- Goa Trance, Psy-Trance"} - - {id: 1611, cat: Audio/MP3, desc: " |- PsyChill, Ambient, Dub"} - - {id: 1457, cat: Audio/MP3, desc: " |- Trance"} - - {id: 1613, cat: Audio/MP3, desc: " |- Hardcore, Hardstyle, Jumpstyle"} - - {id: 1614, cat: Audio/MP3, desc: " |- House"} - - {id: 1203, cat: Audio/MP3, desc: " |- Techno"} - - {id: 1615, cat: Audio/MP3, desc: " |- Electro, Electro-Freestyle, Nu Electro"} - - {id: 1616, cat: Audio/MP3, desc: " |- Drum & Bass, Jungle"} - - {id: 1617, cat: Audio/MP3, desc: " |- Breakbeat"} - - {id: 1618, cat: Audio/MP3, desc: " |- Dubstep"} - - {id: 1205, cat: Audio/MP3, desc: " |- IDM"} - - {id: 1619, cat: Audio/MP3, desc: " |- Chillout, Lounge, Downtempo"} - - {id: 1620, cat: Audio/MP3, desc: " |- Nu Jazz, Acid Jazz, Future Jazz"} - - {id: 1206, cat: Audio/MP3, desc: " |- Trip Hop, Abstract Hip-Hop"} - - {id: 1575, cat: Audio/MP3, desc: " |- Traditional Electronic, Ambient, Modern Classical,..."} - - {id: 1576, cat: Audio/MP3, desc: " |- Industrial, Noise, EBM, Dark Electro, Aggrotech,..."} - - {id: 1577, cat: Audio/MP3, desc: " |- Electronic music (смешанные стили и направления)"} - - {id: 1630, cat: Audio/MP3, desc: "Рэп, Хип-Хоп, R'n'B"} - - {id: 1631, cat: Audio/MP3, desc: " |- Отечественный Рэп, Хип-Хоп (mp3)"} - - {id: 1633, cat: Audio/MP3, desc: " |- Зарубежный Рэп, Хип-Хоп (mp3)"} - - {id: 1540, cat: Audio/MP3, desc: "Остальные музыкальные жанры, + Видеоклипы и концерты"} - - {id: 1604, cat: Audio/MP3, desc: " |- Reggae, Ska, Dub (mp3)"} - - {id: 1562, cat: Audio/MP3, desc: " |- Инструментальная и классическая музыка (mp3, FLAC)"} - - {id: 1185, cat: Audio/MP3, desc: " |- New Age, Relax, Meditative (mp3)"} - - {id: 1183, cat: Audio/MP3, desc: " |- Музыкальное видео (все жанры)"} - - {id: 1664, cat: Audio/Lossless, desc: "Оцифровки с аналоговых носителей"} - - {id: 1665, cat: Audio/Lossless, desc: " |- Зарубежная поп-музыка"} - - {id: 1666, cat: Audio/Lossless, desc: " |- Зарубежная рок-музыка"} - - {id: 1667, cat: Audio/Lossless, desc: " |- Джаз и блюз"} - - {id: 1668, cat: Audio/Lossless, desc: " |- Электронная музыка"} - - {id: 1670, cat: Audio/Lossless, desc: " |- Отечественная поп-музыка"} - - {id: 1746, cat: Audio/Lossless, desc: " |- Русский рок, метал"} - - {id: 1669, cat: Audio/Lossless, desc: " |- Шансон, авторские, военные песни"} - - {id: 1740, cat: Audio/Lossless, desc: " |- Музыка в формате (Hi-Res stereo)"} - - {id: 1679, cat: Audio/Lossless, desc: " |- Зарубежная поп музыка (Lossless)"} - - {id: 1680, cat: Audio/Lossless, desc: " |- Eurodance, Disco, Hi-NRG, Technopop (Lossless)"} - - {id: 1681, cat: Audio/Lossless, desc: " |- Отечественная поп музыка (Lossless)"} - - {id: 1682, cat: Audio/Lossless, desc: " |- Шансон, авторская песня (Lossless)"} - - {id: 1683, cat: Audio/Lossless, desc: " |- Зарубежная рок музыка (Lossless)"} - - {id: 1684, cat: Audio/Lossless, desc: " |- Зарубежный Metal (Lossless)"} - - {id: 1685, cat: Audio/Lossless, desc: " |- Отечественный Rock, Metal (Lossless)"} - - {id: 1686, cat: Audio/Lossless, desc: " |- Зарубежный джаз (Lossless)"} - - {id: 1687, cat: Audio/Lossless, desc: " |- Зарубежный блюз (Lossless)"} - - {id: 1688, cat: Audio/Lossless, desc: " |- Отечественный джаз и блюз (Lossless)"} - - {id: 1689, cat: Audio/Lossless, desc: " |- Электронная и клубная музыка (Lossless)"} - - {id: 1690, cat: Audio/Lossless, desc: " |- Отечественный Рэп, Хип-Хоп (lossless)"} - - {id: 1691, cat: Audio/Lossless, desc: " |- Зарубежный Рэп, Хип-Хоп (lossless)"} - - {id: 1692, cat: Audio/Lossless, desc: " |- Классическая и инструментальная музыка (Lossless)"} - - {id: 1693, cat: Audio/Lossless, desc: " |- Остальные муз.жанры (Lossless)"} - # Программное обеспечение Software - - {id: 105, cat: PC, desc: "Операционные системы"} - - {id: 1663, cat: PC, desc: " |- Windows 11"} - - {id: 1120, cat: PC, desc: " |- Windows 10"} - - {id: 706, cat: PC, desc: " |- Windows 8, 8.1"} - - {id: 212, cat: PC, desc: " |- Windows 7"} - - {id: 210, cat: PC, desc: " |- Windows Vista , Windows XP , Windows выпущенные до 20..."} - - {id: 213, cat: PC, desc: " |- Серверные ОС от Microsoft"} - - {id: 215, cat: PC, desc: " |- Разное (RC, Beta, Service Packs, Cracks)"} - - {id: 1395, cat: PC, desc: " |- Linux, Unix, Mac и другие ОС"} - - {id: 107, cat: PC, desc: "Системные программы"} - - {id: 1405, cat: PC, desc: " |- Драйверы"} - - {id: 1398, cat: PC, desc: " |- Обслуживание и диагностика компьютера"} - - {id: 193, cat: PC, desc: " |- Работа с жёсткими дисками"} - - {id: 1518, cat: PC, desc: " |- Работа с внешними носителями информации"} - - {id: 195, cat: PC, desc: " |- Архиваторы и файловые менеджеры"} - - {id: 341, cat: PC, desc: " |- Программы для интернета и сетей"} - - {id: 196, cat: PC, desc: " |- Настройка и оптимизация и изменение интерфейса ОС"} - - {id: 969, cat: PC, desc: " |- WPI и прочие сборники программ"} - - {id: 1523, cat: PC, desc: " |- LiveCD/DVD/USB"} - - {id: 1505, cat: PC, desc: "Безопасность"} - - {id: 201, cat: PC, desc: " |- Антивирусы"} - - {id: 1506, cat: PC, desc: " |- Фаерволы, брандмауэры"} - - {id: 1508, cat: PC, desc: " |- Антишпионское, антирекламное и антиблокировочное ПО"} - - {id: 1509, cat: PC, desc: " |- Средства защиты информации"} - - {id: 1507, cat: PC, desc: " |- Комплексная защита ПК"} - - {id: 108, cat: PC, desc: "Программы для бизнеса, офиса, научной и проектной работы"} - - {id: 217, cat: PC, desc: " |- Офисные системы"} - - {id: 218, cat: PC, desc: " |- Системы для бизнеса"} - - {id: 222, cat: PC, desc: " |- Системы для научной работы"} - - {id: 1404, cat: PC, desc: " |- САПР, софт для архитекторов, дизайнеров и пр."} - - {id: 1522, cat: PC, desc: " |- Справочные системы, базы данных"} - - {id: 1504, cat: PC, desc: " |- Программы для верстки, печати и работы со шрифтами"} - - {id: 220, cat: PC, desc: " |- Работа с PDF, DjVu, FB2, EPUB, MOBI и пр."} - - {id: 221, cat: PC, desc: " |- Словари, переводчики, программы обучения языкам"} - - {id: 219, cat: PC, desc: " |- Распознавание текста, звука и синтез речи"} - - {id: 1511, cat: PC, desc: "Программирование и веб-разработка"} - - {id: 1512, cat: PC, desc: " |- WYSIWYG-редакторы для веб-дизайна"} - - {id: 1513, cat: PC, desc: " |- Текстовые редакторы с подсветкой кода"} - - {id: 1514, cat: PC, desc: " |- Среды программирования, компиляторы и вспомогател..."} - - {id: 1515, cat: PC, desc: " |- Системы управления базами данных (СУБД)"} - - {id: 1516, cat: PC, desc: " |- Скрипты и шаблоны"} - - {id: 110, cat: PC, desc: "Программы для работы с мультимедиа и 3D"} - - {id: 966, cat: PC, desc: " |- Графические редакторы и каталогизаторы"} - - {id: 1500, cat: PC, desc: " |- Видеоредакторы"} - - {id: 1501, cat: PC, desc: " |- Аудиоредакторы"} - - {id: 967, cat: PC, desc: " |- Аудио- Видео- проигрыватели и каталогизаторы"} - - {id: 965, cat: PC, desc: " |- Аудио- Видео- грабберы, рипперы и конвертеры"} - - {id: 1499, cat: PC, desc: " |- Анимация"} - - {id: 1502, cat: PC, desc: " |- Программные комплекты"} - - {id: 1503, cat: PC, desc: " |- Дополнительные материалы для мультимедиа"} - - {id: 968, cat: PC, desc: " |- Разное (Программы для работы с мультимедиа и 3D)"} - # Все для мобильных устройств Mobiles - - {id: 1287, cat: PC/Mobile-Other, desc: "Видео"} - - {id: 1307, cat: PC/Mobile-Other, desc: " |- Мобильные телефоны"} - - {id: 1306, cat: PC/Mobile-iOS, desc: " |- iPhone"} - - {id: 1305, cat: PC/Mobile-iOS, desc: " |- iPad"} - - {id: 1289, cat: PC, desc: "Программы"} - - {id: 1302, cat: PC, desc: " |- Мобильные телефоны"} - - {id: 1301, cat: PC/Mobile-Android, desc: " |- Android OS"} - - {id: 1298, cat: PC, desc: " |- Symbian OS"} - - {id: 1293, cat: PC/Mobile-Other, desc: " |- Windows Mobile"} - - {id: 1292, cat: PC/Mobile-iOS, desc: " |- iPhone"} - - {id: 1291, cat: PC/Mobile-iOS, desc: " |- Apple Macintosh"} - - {id: 1294, cat: PC, desc: "Игры"} - - {id: 1303, cat: PC, desc: " |- Мобильные телефоны"} - - {id: 1300, cat: PC/Mobile-Android, desc: " |- Android OS"} - - {id: 1299, cat: PC, desc: " |- Symbian OS"} - - {id: 1296, cat: PC/Mobile-iOS, desc: " |- iPhone"} - - {id: 1295, cat: PC/Mobile-Other, desc: " |- Windows Mobile"} - # Клубничка ХХХ 18+️ - - {id: 1490, cat: XXX, desc: "Эротика (не порно)"} - - {id: 1491, cat: XXX, desc: " |- ❥ Эротические фильмы ≪с переводом Rus≫"} - - {id: 1492, cat: XXX, desc: " |- • Эротические фильмы с «родной» озвучкой"} - - {id: 1493, cat: XXX, desc: " |- • Playboy, Penthouse (эротический видео журнал)"} - - {id: 1495, cat: XXX, desc: " |- • Нудизм-Натуризм"} - - {id: 1494, cat: XXX, desc: " |- • Фото, картинки, журналы"} - - {id: 1376, cat: XXX, desc: "Зарубежное видео ХХХ"} - - {id: 1378, cat: XXX, desc: " |- • Зарубежные порно фильмы"} - - {id: 1470, cat: XXX, desc: " |- • Гонзо-фильмы / Gonzo"} - - {id: 1446, cat: XXX, desc: " |- • Гонзо и зарубежные фильмы ≪720p, 1080p≫"} - - {id: 1468, cat: XXX, desc: " |- • Этнические фильмы / Ethnic-Themed"} - - {id: 1537, cat: XXX, desc: " |- • Лесбо-фильмы / All Girl & Solo"} - - {id: 1444, cat: XXX, desc: " |- • Порно пародии / Parody porn"} - - {id: 1379, cat: XXX, desc: " |- • Зарубежное порно ≪с переводом Rus≫"} - - {id: 1388, cat: XXX, desc: " |- • Классика и ретро порно / Classic (Retro)"} - - {id: 1389, cat: XXX, desc: " |- • Японские фильмы и сайтрипы"} - - {id: 1380, cat: XXX, desc: " |- • Зарубежные сайтрипы ≪Definition 480p, 540p≫"} - - {id: 1441, cat: XXX, desc: " |- • Зарубежные сайтрипы ≪HD Video 720p, 1080p≫"} - - {id: 1439, cat: XXX, desc: " |- • Сцены и Сайтрипы ≪4К UHD 2160p≫"} - - {id: 1535, cat: XXX, desc: " |- • Виртуальная реальность ХХХ / Virtual Reality Porn (VR)"} - - {id: 1392, cat: XXX, desc: " |- • Любительское видео ≪WebCam, CamRip≫"} - - {id: 1426, cat: XXX, desc: " |- • Документальное видео"} - - {id: 1377, cat: XXX, desc: "Русское видео ХХХ"} - - {id: 1383, cat: XXX, desc: " |- • Порно фильмы Российского пр-ва"} - - {id: 1387, cat: XXX, desc: " |- • Сайтрипы с русскими актрисами ≪Definition 480p, 540p≫"} - - {id: 1428, cat: XXX, desc: " |- • Сайтрипы с русскими актрисами ≪HD Video 720p, 1080p≫"} - - {id: 1440, cat: XXX, desc: " |- • Сцены и Сайтрипы с Русскими актрисами ≪4К UHD 2160p≫"} - - {id: 1384, cat: XXX, desc: " |- • Любительское порно ≪CamRip≫"} - - {id: 1429, cat: XXX, desc: "Нетрадиционное порно"} - - {id: 1430, cat: XXX, desc: " |- • Буккаке, Камшот / Bukkake, Cumshot Compilation"} - - {id: 1431, cat: XXX, desc: " |- • Женское доминирование и страпон / Femdom & Strapon"} - - {id: 1432, cat: XXX, desc: " |- • BDSM ≪бандаж, дисциплина, садизм, мазохизм≫"} - - {id: 1433, cat: XXX, desc: " |- • Фетиш, медосмотры / Medical Fetish"} - - {id: 1442, cat: XXX, desc: " |- • Переодевание, униформа / Uniform"} - - {id: 1434, cat: XXX, desc: " |- • Фистинг и дилдо / Fisting & Dildo"} - - {id: 1469, cat: XXX, desc: " |- • Транссексуалы / Transsexual"} - - {id: 1443, cat: XXX, desc: " |- • Мочеиспускание / Peeing"} - - {id: 1536, cat: XXX, desc: " |- • Подглядывание / Voyeur"} - - {id: 1438, cat: XXX, desc: " |- • Беременные / Pregnant"} - - {id: 1496, cat: XXX, desc: " |- • Бисексуалы / Bisexual"} - - {id: 1480, cat: XXX, desc: "Мультфильмы ХХХ & Хентай & Порно игры"} - - {id: 1542, cat: XXX, desc: " |- • Порно игры / Porno games"} - - {id: 1481, cat: XXX, desc: " |- • Мультфильмы / Cartoons"} - - {id: 1482, cat: XXX, desc: " |- • Хентай / Hentai • •"} - # Разное Other - - {id: 118, cat: Other, desc: "Разное"} - - {id: 987, cat: Other, desc: " |- Обои для рабочего стола"} - - {id: 1068, cat: Other, desc: " |- Эротические обои"} - - {id: 1424, cat: Other, desc: " |- Боевые искусства (Видеоуроки)"} - - {id: 1425, cat: Other, desc: " |- Видеоуроки и обучающие интерактивные DVD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -search: - paths: - - path: tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 1 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - prev_tor_type: 0 -# prev_snotd: 0 -# prev_a: 0 -# prev_not_checked: 0 -# prev_closed: 0 -# prev_d: 0 -# prev_not_perfect: 0 -# prev_part_perfect: 0 -# prev_fishily: 0 -# prev_copy: 0 -# prev_temp: 0 -# prev_pogl: 0 -# prev_mod: 0 -# prev_gold: 0 -# prev_silver: 0 - # sort by 1=registered 2=topicTitle 4=completed 10=seed 11=leech 12=up 13=down 5=replies 6=views 7=size 8=lastPost 9=seedLastSeen - o: "{{ .Config.sort }}" - # 1=asc 2=desc - s: "{{ .Config.type }}" - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # show speed - ds: 0 - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - # group releases -1=not selected - srg: -1 - nm: "{{ .Keywords }}" - # search by partial word - allw: 0 - - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: tr[id^="tor_"]:has(a[href^="magnet:?xt="]) - - fields: - category: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a[href^="./viewtopic.php?t="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="./viewtopic.php?t="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-child(6) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - selector: td:last-child > u - downloadvolumefactor: - text: 0 -# case: -# img[src="styles/images/tor_gold.gif"]: 0 -# img[src="styles/images/tor_silver.gif"]: 0.5 -# "*": 1 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="./viewtopic.php?t="] -# TorrentPier II diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/samaritano.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/samaritano.yml deleted file mode 100644 index c33ef05b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/samaritano.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: samaritano -name: SAMARITANO (API) -description: "SAMARITANO is a BRAZILIAN Private tracker for MOVIES / TV / GENERAL" -language: pt-BR -type: private -encoding: UTF-8 -links: - - https://samaritano.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Filmes"} - - {id: 2, cat: TV, desc: "Séries"} - - {id: 3, cat: TV/Anime, desc: "Animes"} - - {id: 4, cat: Other, desc: "Cursos"} - - {id: 5, cat: Console, desc: "Games"} - - {id: 6, cat: Books/EBook, desc: "Livros"} - - {id: 7, cat: Books/Comics, desc: "HQs e Mangas"} - - {id: 8, cat: Audio/Audiobook, desc: "AudioBooks"} - - {id: 9, cat: PC, desc: "Programas"} - - {id: 11, cat: Other, desc: "Diversos"} - - {id: 10, cat: Other, desc: "Materiais de Apoio"} - - {id: 12, cat: Audio, desc: "Músicas"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your SAMARITANO account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Your account will be considered inactive if you have not logged in to the site for more than 90 days. After 30 days of inactivity (i.e. 30 days without logging in), your account will be removed from the tracker. Please note: The groups that suffer from inactivity are: User and Leech." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Cinema TV)", "Cinema_TV"] - - name: re_replace - args: ["(?i)(Ficção científica)", "Ficção_científica"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sbpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sbpt.yml deleted file mode 100644 index fa093899..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sbpt.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: sbpt -name: SBPT -description: "SBPT is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://sbpt.link/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影(Movies)"} - - {id: 408, cat: Audio, desc: "音乐(HQ Audio)"} - - {id: 409, cat: Other, desc: "其他(Misc)"} - - {id: 407, cat: TV/Sport, desc: "体育(Sport)"} - - {id: 406, cat: Audio/Video, desc: "音乐短片(MV)Music Video"} - - {id: 403, cat: TV, desc: "综艺(TV Shows)"} - - {id: 402, cat: TV, desc: "电视剧(TVSeries)"} - - {id: 405, cat: TV/Anime, desc: "动画/Animation)"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentary)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the SBPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User or above will be retained forever
  2. Elite User or abovee will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 365 consecutive days
  5. Users with no traffic (i.e. upload/download data is 0) will be deleted if they do not log in for 60 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 5 hours (as seconds = 5 x 60 x 60) - text: 18000 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.13 2024-09-21 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/scenerush.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/scenerush.yml deleted file mode 100644 index a01b4dde..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/scenerush.yml +++ /dev/null @@ -1,210 +0,0 @@ ---- -id: scenerush -name: SceneRush -description: "SceneRush is a PORTUGUESE Private Torrent Tracker for 0DAY / GENERAL" -language: pt-PT -type: private -encoding: UTF-8 -links: - - https://www.scene-rush.pt/ - - https://www.scene-rush.com/ -legacylinks: - - http://www.scene-rush.pt/ - -caps: - categorymappings: - - {id: 15, cat: Console/PS3, desc: "JOGO-PS"} - - {id: 11, cat: Console/XBox, desc: "JOGO-XBOX"} - - {id: 5, cat: PC/Games, desc: "JOGO-PC"} - - {id: 33, cat: Console/NDS, desc: "JOGO-ND"} - - {id: 38, cat: Movies/Other, desc: "CAM TS"} - - {id: 23, cat: Movies, desc: "FILME-PACK"} - - {id: 53, cat: Movies/SD, desc: "FILME-SD"} - - {id: 6, cat: Movies/HD, desc: "FILME-HD"} - - {id: 46, cat: Movies/UHD, desc: "FILME-4K"} - - {id: 32, cat: Movies/BluRay, desc: "FILME-BRAY"} - - {id: 3, cat: Movies/DVD, desc: "FILME-DVD"} - - {id: 42, cat: Movies/WEB-DL, desc: "FILME-WEB"} - - {id: 9, cat: Audio, desc: "ALBUNS"} - - {id: 14, cat: Audio/Video, desc: "MUSIC-VID"} - - {id: 1, cat: PC, desc: "APPS"} - - {id: 21, cat: PC/Mac, desc: "MAC"} - - {id: 36, cat: PC/Mobile-Android, desc: "ANDROID"} - - {id: 4, cat: TV/SD, desc: "TV-SD"} - - {id: 17, cat: TV/HD, desc: "TV-HD"} - - {id: 48, cat: TV/UHD, desc: "TV-4K"} - - {id: 47, cat: TV/Other, desc: "TV-PACK"} - - {id: 16, cat: TV/Sport, desc: "WWE"} - - {id: 44, cat: TV/Sport, desc: "SPORTS"} - - {id: 13, cat: TV/Documentary, desc: "DOC"} - - {id: 49, cat: TV/Anime, desc: "ANIME-4K"} - - {id: 20, cat: TV/Anime, desc: "ANIME-DVD"} - - {id: 35, cat: TV/Anime, desc: "ANIME-HD"} - - {id: 50, cat: TV/Anime, desc: "ANIME-PACK"} - - {id: 8, cat: TV/Anime, desc: "ANIME-SD"} - - {id: 51, cat: TV/Anime, desc: "ANIME-WEB"} - - {id: 7, cat: XXX, desc: "XXX"} - - {id: 43, cat: Books/EBook, desc: "EBOOK"} - - {id: 52, cat: Books/Other, desc: "CURSOS"} - - {id: 31, cat: Other, desc: "OFF"} - - {id: 10, cat: Other, desc: "OUTROS"} - - {id: 39, cat: Other, desc: "GPS"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: h2:contains("Falhou") - message: - selector: table tr td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 gold - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 name, 1 descr, 2 both - blah: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[cellpadding='5'][width='96%'] > tbody > tr:has(a[href*="details.php?id="]) - - fields: - category: - selector: td:first-of-type - case: - div.cat-filme-4k: 46 - div.cat-albuns: 9 - div.cat-android: 36 - div.cat-anime-sd: 8 - div.cat-anime-dvd: 20 - div.cat-anime-hd: 35 - div.cat-anime-pack: 50 - div.cat-anime-web: 51 - div.cat-anime-4k: 49 - div.cat-apps: 1 - div.cat-cam-ts: 38 - div.cat-cursos: 52 - div.cat-doc: 13 - div.cat-e-book: 43 - div.cat-filme-bray: 32 - div.cat-filme-dvd: 3 - div.cat-filme-hd: 6 - div.cat-filme-pack: 23 - div.cat-filme-sd: 53 - div.cat-filme-web: 42 - div.cat-gps: 39 - div.cat-jogo-nd: 33 - div.cat-jogo-pc: 5 - div.cat-jogo-ps: 15 - div.cat-jogo-xbox: 11 - div.cat-mac: 21 - div.cat-music-video: 14 - div.cat-off: 31 - div.cat-other: 10 - div.cat-sports: 44 - div.cat-tv-4k: 48 - div.cat-tv-hd: 17 - div.cat-tv-pack: 47 - div.cat-tv-sd: 4 - div.cat-wwe: 16 - div.cat-xxx: 7 - _audiopt: - selector: i.fg-audiopt - attribute: title - optional: true - title: - selector: a[href^="details.php?id="] - filters: - - name: append - args: "{{ if .Result._audiopt }} Portuguese PT-PT{{ else }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: script - filters: - - name: regexp - args: "src=\"(.+?)\"" - size: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(10) - seeders: - selector: td:nth-child(11) - leechers: - selector: td:nth-child(12) - date: - selector: td:nth-child(7) - filters: - - name: re_replace - args: ["(\\d{4}-\\d{2}-\\d{2})(\\d{2}:\\d{2}:\\d{2})", "$1 $2"] - - name: append - args: " +00:00" # WET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - case: - "i.fg-gold": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/seedfile.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/seedfile.yml deleted file mode 100644 index 156817c2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/seedfile.yml +++ /dev/null @@ -1,155 +0,0 @@ ---- -id: seedfile -name: SeedFile -description: "SeedFile (SF) is a ROMANIAN Private Torrent Tracker for 0DAY / GENERAL" -language: ro-RO -type: private -encoding: UTF-8 -links: - - https://seedfile.io/ -legacylinks: - - https://www.seedfile.io/ - - https://www.seedfile.ro/ - -caps: - categorymappings: - - {id: 1, cat: Books/Mags, desc: "Cărți/Reviste"} - - {id: 2, cat: Other, desc: "Desene SD"} - - {id: 3, cat: Other, desc: "Diverse n/a"} - - {id: 5, cat: Movies/BluRay, desc: "Filme Blu-Ray"} - - {id: 6, cat: Movies/DVD, desc: "Filme DVD"} - - {id: 7, cat: Movies/DVD, desc: "Filme DVD-RO"} - - {id: 8, cat: Movies/HD, desc: "Filme HD"} - - {id: 9, cat: Movies/HD, desc: "Filme HD-RO"} - - {id: 10, cat: Movies/SD, desc: "Filme SD"} - - {id: 11, cat: Movies/SD, desc: "Filme SD-RO"} - - {id: 12, cat: Other, desc: "Imagini"} - - {id: 13, cat: Console, desc: "Jocuri Console"} - - {id: 14, cat: PC/Games, desc: "Jocuri PC"} - - {id: 15, cat: Audio, desc: "Muzică"} - - {id: 16, cat: PC/Mobile-Other, desc: "Mobile"} - - {id: 17, cat: PC, desc: "Programe"} - - {id: 18, cat: TV/HD, desc: "Seriale HD"} - - {id: 19, cat: TV/HD, desc: "Seriale HD-RO"} - - {id: 20, cat: TV/SD, desc: "Seriale TV"} - - {id: 21, cat: TV/SD, desc: "Seriale TV-RO"} - - {id: 22, cat: TV/Sport, desc: "Sport"} - - {id: 23, cat: Audio/Video, desc: "Video Clip"} - - {id: 24, cat: XXX, desc: "Adult 18+"} - - {id: 36, cat: Movies/3D, desc: "Video 3D"} - - {id: 37, cat: Movies/UHD, desc: "Filme 4K"} - - {id: 38, cat: Movies/UHD, desc: "Filme 4K RO"} - - {id: 40, cat: Movies/BluRay, desc: "Filme Blu-Ray RO"} - - {id: 41, cat: TV/UHD, desc: "Seriale 4K"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_activity - type: info - label: Account Inactivity - default: "Typically, (unparked) accounts are deleted after one year from last access. With account parking, the maximum period of inactivity is 2 years, after which the account will be deleted." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.recover-error2 - test: - path: home - selector: a[href^="/logout.php?id="] - -download: - selectors: - - selector: a[href^="download.php/"] - attribute: href - -search: - paths: - - path: download-torrents - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - - rows: - selector: tr.browse - - fields: - category: - selector: a[href^="torrents.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - poster: - selector: td.numele_tor span[data-original-title] - attribute: data-original-title - filters: - - name: regexp - args: "src='(.+?)'" - genre: - selector: td.numele_tor small - filters: - - name: replace - args: ["Genre:\xA0", ""] - description: - case: - b[title="Verified"]: "Verified: {{ .Result.genre }}" - "*": "Unverified: {{ .Result.genre }}" - files: - selector: td:nth-last-child(8) - date: - selector: td:nth-last-child(6) - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "HH:mm:ssyyyy-MM-dd zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - "img[src=\"./pic/freeleech.png\"]": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/seedpool-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/seedpool-api.yml deleted file mode 100644 index de343e7e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/seedpool-api.yml +++ /dev/null @@ -1,219 +0,0 @@ ---- -id: seedpool-api -name: seedpool (API) -description: "seedpool is a Private Torrent Tracker for 0-DAY MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://seedpool.org/ - -caps: - categorymappings: - - {id: 2, cat: TV, desc: "TV Show"} - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 6, cat: TV/Anime, desc: "Anime"} - - {id: 6, cat: Movies/Other, desc: "Anime"} # for Radarr - - {id: 8, cat: TV/Sport, desc: "Sports"} - - {id: 5, cat: Audio, desc: "Music"} - - {id: 9, cat: Audio/Audiobook, desc: "Audiobook"} - - {id: 7, cat: Books/EBook, desc: "E-Book"} - - {id: 12, cat: Other, desc: "Hobby"} - - {id: 16, cat: PC, desc: "Software"} - # Seedpool has a combined Game category - - {id: 3, cat: Console, desc: "Game"} - - {id: 3, cat: PC/Games, desc: "Game"} - # Music Production category can have software and audio samples - - {id: 21, cat: PC, desc: "Music Production"} - - {id: 21, cat: Audio/Other, desc: "Music Production"} - - {id: 21, cat: Other, desc: "Music Production"} - - {id: 11, cat: Other, desc: "Other"} - - {id: 13, cat: TV, desc: "Deprecated - BoxSet"} - - {id: 14, cat: PC/Games, desc: "Deprecated - Game"} - - {id: 15, cat: Other, desc: "Deprecated - Education"} - - {id: 10, cat: Movies/UHD, desc: "Deprecated - 4K Movie"} - - {id: 17, cat: PC/0day, desc: "Deprecated - Linux"} - - {id: 18, cat: PC/Mac, desc: "Deprecated - macOS"} - - {id: 19, cat: Console, desc: "Deprecated - Consoles"} - - {id: 20, cat: TV/Foreign, desc: "Deprecated - JPTV"} - - {id: 4, cat: Console/Other, desc: "Deprecated - NSW Game"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your seedpool account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: tmdbidonly - type: checkbox - label: Disable IMDB and TVDB ID search (only support TMDB ID) to potentially improve Sonarr and Radarr results - default: true - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.IMDBIDShort }}{{ end }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.TVDBID }}{{ end }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 1.0 but torrents must be seeded for 10 days regardless of ratio -# minimumratio: -# text: 1.0 - minimumseedtime: - # 10 days (as seconds = 10 x 24 x 60 x 60) - text: 864000 -# json UNIT3D 9.1.5 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/selezen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/selezen.yml deleted file mode 100644 index 8e142bd3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/selezen.yml +++ /dev/null @@ -1,241 +0,0 @@ ---- -id: selezen -name: seleZen -description: "seleZen is a RUSSIAN Semi-Private Torrent Tracker for MOVIES" -language: ru-RU -type: semi-private -encoding: UTF-8 -links: - - https://use.selezen.club/ - - https://www.selezen.club/ - - https://open.selezen.org/ - - https://www.selezen.top/ - - https://open.selezen.top/ -legacylinks: - - https://www.selezen.site/ - - https://mel.selezen.net/ - - https://s1.selezen.site/ - - https://www.selezen.net/ - - https://selezen.org/ - -caps: - categorymappings: - - {id: 0, cat: Movies, desc: "Movies"} - - {id: 40, cat: Movies/BluRay, desc: "Blu-Ray"} - - {id: 78, cat: Other, desc: "RePack от селезень"} - - {id: 45, cat: Movies/HD, desc: "WEBRip 720p"} - - {id: 46, cat: Movies/HD, desc: "WEBRip 1080p"} - - {id: 47, cat: Movies/UHD, desc: "WEBRip 2160p"} - - {id: 48, cat: Movies/HD, desc: "WEB-DL 720p"} - - {id: 49, cat: Movies/HD, desc: "WEB-DL 1080p"} - - {id: 50, cat: Movies/UHD, desc: "SDR WEB-DL 2160p"} - - {id: 51, cat: Movies/UHD, desc: "HDR WEB-DL 2160p"} - - {id: 52, cat: Movies/HD, desc: "BDRip 720p"} - - {id: 53, cat: Movies/HD, desc: "BDRip 1080p"} - - {id: 54, cat: Movies/UHD, desc: "SDR BDRip 2160p"} - - {id: 55, cat: Movies/UHD, desc: "HDR BDRip 2160p"} - - {id: 56, cat: Movies/HD, desc: "UHD BDRip 720p"} - - {id: 57, cat: Movies/HD, desc: "UHD BDRip 1080p"} - - {id: 58, cat: Movies/HD, desc: "BDRemux 1080p"} - - {id: 59, cat: Movies/UHD, desc: "SDR BDRemux 2160p"} - - {id: 60, cat: Movies/UHD, desc: "HDR BDRemux 2160p"} - - {id: 61, cat: Movies/UHD, desc: "DV BDRemux 2160p"} - - {id: 62, cat: Movies/HD, desc: "Blu-Ray 1080p"} - - {id: 63, cat: Movies/UHD, desc: "Blu-Ray 2160p"} - - {id: 64, cat: Movies/3D, desc: "3D BDRemux 1080p"} - - {id: 65, cat: Movies/3D, desc: "3D Blu-Ray 1080p"} - - {id: 74, cat: Movies/HD, desc: "WEBRip"} - - {id: 75, cat: Movies/HD, desc: "BDRip"} - - {id: 76, cat: Movies/HD, desc: "WEB-DLRip"} - - {id: 77, cat: Movies/HD, desc: "HDRip"} - - {id: 69, cat: Movies, desc: "WEB-DLRip-AVC"} - - {id: 70, cat: Movies, desc: "HDRip-AVC"} - - {id: 71, cat: Movies, desc: "BDRip-AVC"} - - {id: 72, cat: Movies/HD, desc: "WEB-DLRip 720p"} - - modes: - search: [q] - movie-search: [q, imdbid] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - title: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.html - method: form - form: form[role="form"] - inputs: - login_name: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - login_not_save: "" - login: submit - error: - - selector: div.alert-warning:contains("Ошибка авторизации") - test: - path: / - selector: a[href$="/index.php?action=logout"] - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - # do=search&subaction=search&story=greyhound&titleonly=3&sortby=date&resorder=desc&showposts=1&catlist[]=49 - paths: - - path: index.php - followredirect: true - inputs: - $raw: "{{ range .Categories }}catlist[]={{.}}&{{end}}" - do: search - subaction: search - showposts: 1 - # 0 in article 1 in comments 2 in static pages 3 in article titles - titleonly: "{{ if .Query.IMDBID }}0{{ else }}3{{ end }}" - story: "{{ if or .Query.IMDBID .Keywords }}{{ or .Query.IMDBID .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - sortby: "{{ .Config.sort }}" - resorder: "{{ .Config.type }}" - - rows: - selector: div.card:has(h4):not(:has(a[href*="/news/"])):has(a:has(span.bx-download)) - - fields: - category: - selector: h4 - case: - "h4:contains(\"WEBRip 720p\")": 45 - "h4:contains(\"WEBRip 1080p\")": 46 - "h4:contains(\"WEBRip 2160p\")": 47 - "h4:contains(\"SDR WEB-DL 2160p\")": 50 - "h4:contains(\"HDR WEB-DL 2160p\")": 51 - "h4:contains(\"WEB-DL 720p\")": 48 - "h4:contains(\"WEB-DL 1080p\")": 49 - "h4:contains(\"SDR BDRip 2160p\")": 54 - "h4:contains(\"HDR BDRip 2160p\")": 55 - "h4:contains(\"UHD BDRip 720p\")": 56 - "h4:contains(\"UHD BDRip 1080p\")": 57 - "h4:contains(\"SDR BDRemux 2160p\")": 59 - "h4:contains(\"HDR BDRemux 2160p\")": 60 - "h4:contains(\"DV BDRemux 2160p\")": 61 - "h4:contains(\"3D BDRemux 1080p\")": 64 - "h4:contains(\"3D Blu-Ray 1080p\")": 65 - "h4:contains(\"BDRemux 1080p\")": 58 - "h4:contains(\"Blu-Ray 1080p\")": 62 - "h4:contains(\"Blu-Ray 2160p\")": 63 - "h4:contains(\"BDRip 720p\")": 52 - "h4:contains(\"BDRip 1080p\")": 53 - "h4:contains(\"WEB-DLRip-AVC\")": 69 - "h4:contains(\"HDRip-AVC\")": 70 - "h4:contains(\"BDRip-AVC\")": 71 - "h4:contains(\"WEB-DLRip 720p\")": 72 - "h4:contains(\"WEBRip\")": 74 - "h4:contains(\"WEB-DLRip\")": 76 - "h4:contains(\"WEB-DL\")": 74 - "h4:contains(\"BDRip\")": 75 - "h4:contains(\"HDRip\")": 77 - "h4:contains(\"Blu-Ray\")": 40 - "h4:contains(\"RePack\")": 78 - "*": 0 - title: - selector: h4 - filters: - - name: re_replace - args: ["(?i)\\bЛицензия\\b", "Lic"] - - name: re_replace - args: ["(?i)\\bселезень\\b", "selezen"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a:has(h4) - attribute: href - download: - selector: a:has(h4) - attribute: href - poster: - selector: img.card-img - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: div.cats - filters: - - name: validate - args: "Аниме, Биографический, Боевик, Вестерн, Военный, Детектив, Документальный, Драма, Исторический, Комедия, Короткометражный, Криминал, Мелодрама, Мистика, Музыкальный, Мультфильм, Приключения, Семейный, Спорт, Триллер, Ужасы, Фантастика, Фэнтези" - description: - selector: p.card-text - filters: - - name: append - args: "{{ if .Result.genre }}
{{ .Result.genre }}{{ else }}{{ end }}" - date: - selector: a:has(span.bx-calendar) - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "d.MM.yyyy HH:mm zzz" - seeders: - selector: a:has(i.bx-chevrons-up) - filters: - - name: split - args: [" ", 0] - leechers: - selector: a:has(i.bx-chevrons-up) - filters: - - name: split - args: [" ", 1] - grabs: - selector: a:has(i.bx-chevrons-up) - filters: - - name: split - args: [" ", -1] - size: - selector: a:has(span.bx-download) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# DataLife v(custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sewerpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sewerpt.yml deleted file mode 100644 index dfb5e295..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sewerpt.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: sewerpt -name: SewerPT -description: "SewerPT (下水道) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://sewerpt.com/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影 / Movies"} - - {id: 402, cat: TV, desc: "电视剧 / TV Series"} - - {id: 403, cat: TV/Documentary, desc: "综艺 / TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "纪录片 / Documentaries"} - - {id: 405, cat: TV/Anime, desc: "动漫 / Animations"} - - {id: 408, cat: Audio, desc: "音乐 / Music"} - - {id: 409, cat: Other, desc: "其他 / Misc"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the SewerPT Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Ultimate User and above will be retained forever
  2. Veteran Users and above will not be deleted after parking (in the control panel)
  3. Users with a parked account will be banned if they do not log in for 400 consecutive days
  4. Users who do not log in for 30 consecutive days will be banned
  5. Newly registered users with no traffic within 7 days, will be banned.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - - selector: td.embedded:has(:contains("两步验证")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (not working) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[title][href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - "*": 1 - uploadvolumefactor: - case: - img.pro_free2up: 2 - img.pro_50pctdown2up: 2 - "*": 1 - minimumratio: - case: - img[title="H&R"]: 1.0 - "*": 0.4 - minimumseedtime: - case: - img[title="H&R"]: 259200 - "*": 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, font, img, span -# NexusPHP v1.8.15 2024-12-23 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sextorrent-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sextorrent-api.yml deleted file mode 100644 index 6af70c90..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sextorrent-api.yml +++ /dev/null @@ -1,214 +0,0 @@ ---- -id: sextorrent-api -name: SexTorrent (API) -description: "SexTorrent is a Private Torrent Tracker for 3X" -language: en-US -type: private -encoding: UTF-8 -links: - - https://sextorrent.myds.me/ - -caps: - categorymappings: - - {id: 4, cat: XXX, desc: "Hardcore"} - - {id: 5, cat: XXX, desc: "Anal"} - - {id: 6, cat: XXX, desc: "Asian"} - - {id: 7, cat: XXX, desc: "BDSM"} - - {id: 8, cat: XXX, desc: "Big.Tits"} - - {id: 9, cat: XXX, desc: "Busty"} - - {id: 10, cat: XXX, desc: "Creampie"} - - {id: 11, cat: XXX, desc: "Fetish"} - - {id: 12, cat: XXX, desc: "Mature"} - - {id: 13, cat: XXX, desc: "Amateur"} - - {id: 14, cat: XXX, desc: "Old and Young"} - - {id: 15, cat: XXX, desc: "Milf"} - - {id: 16, cat: XXX, desc: "Oral"} - - {id: 17, cat: XXX, desc: "Orgy GangBang"} - - {id: 18, cat: XXX, desc: "Lesbian"} - - {id: 19, cat: XXX, desc: "Teen"} - - {id: 20, cat: XXX/x264, desc: "HD High.Definition"} - - {id: 21, cat: XXX/Other, desc: "Cartoon Hentai"} - - {id: 22, cat: XXX, desc: "Interracial"} - - {id: 23, cat: XXX, desc: "Masturbate"} - - {id: 24, cat: XXX/Other, desc: "Music.Video"} - - {id: 25, cat: XXX, desc: "POV"} - - {id: 26, cat: XXX, desc: "Shemale TS Trans.Sex"} - - {id: 27, cat: XXX, desc: "Straight"} - - {id: 28, cat: XXX/Pack, desc: "Mega.PACK"} - - {id: 29, cat: XXX, desc: "VR - Porn"} - - {id: 30, cat: XXX/ImageSet, desc: "Pictures XXX"} - - {id: 31, cat: XXX, desc: "SiteRip"} - - {id: 32, cat: XXX, desc: "Foreign"} - - {id: 33, cat: XXX, desc: "Latina"} - - {id: 34, cat: XXX/Other, desc: "Games All type"} - - {id: 35, cat: XXX/Other, desc: "Magazines (Press.)"} - - {id: 36, cat: XXX, desc: "Feature"} - - {id: 37, cat: XXX, desc: "Big-Ass"} - - {id: 38, cat: XXX/Other, desc: "Other (all.mixt.)"} - - {id: 39, cat: XXX, desc: "CumShot"} - - {id: 40, cat: XXX, desc: "Black"} - - {id: 41, cat: XXX, desc: "Piss"} - - {id: 42, cat: XXX, desc: "Homemade"} - - {id: 43, cat: XXX, desc: "Pregnant"} - - {id: 44, cat: XXX, desc: "GAY"} - - {id: 45, cat: XXX, desc: "Classic"} - - {id: 46, cat: XXX, desc: "Threesome"} - - {id: 1, cat: XXX, desc: "Movies XXX"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 47, cat: XXX, desc: "Unsorted"} - - {id: 48, cat: XXX, desc: "OnlyFans .com"} - - modes: - search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your SexTorrent account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive Account mean No trafic Up/Down, these will be wiped very fast." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.7 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.7 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sexypics.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sexypics.yml deleted file mode 100644 index 4beaa00e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sexypics.yml +++ /dev/null @@ -1,86 +0,0 @@ ---- -id: sexypics -name: Sexy-Pics -description: "Sexy-Pics is a Public Magnet Links site for 3X MP4" -language: en-US -type: public -encoding: UTF-8 -followredirect: true -requestDelay: 2 -links: - - https://www.sexy-pics.us/ - -caps: - categories: - XXX: XXX - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: age - options: - age: created - se: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - -search: - headers: - # prevent redirect - Accept: ["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"] - keywordsfilters: - # replace space between keywords with - to prevent 404 Not Found - - name: re_replace - args: [" ", "-"] - - name: tolower - paths: - # return results for 'of' if there are no search parms supplied (for use with the TEST button) - # https://www.sexy-pics.us/h/hegre-1080p/age/desc/ - # https://www.sexy-pics.us/browse/all/age/desc/2/ - - path: "{{ if .Keywords }}{{ re_replace .Keywords \"(.).*\" \"$1\" }}/{{ .Keywords }}/{{ else }}browse/all/{{ end }}{{ .Config.sort }}/{{ .Config.type }}/" - - path: "{{ if .Keywords }}{{ re_replace .Keywords \"(.).*\" \"$1\" }}/{{ .Keywords }}/{{ else }}browse/all/{{ end }}{{ .Config.sort }}/{{ .Config.type }}/2/" - - rows: - selector: tr:has(td.m) - - fields: - category: - text: XXX - title: - selector: td.n a - attribute: title - details: - selector: td.n a - attribute: href - magnet: - selector: td.m a - attribute: href - date: - selector: td:nth-child(3) - filters: - - name: timeago - files: - selector: td:nth-child(5) - size: - selector: td:nth-child(6) - seeders: - selector: td.s - leechers: - selector: td.l - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shakaw.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shakaw.yml deleted file mode 100644 index f107713a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shakaw.yml +++ /dev/null @@ -1,127 +0,0 @@ ---- -id: shakaw -name: ShaKaw -description: "ShaKaw is a BRAZILIAN Private Torrent Tracker for ANIME" -language: pt-BR -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://tracker.shakaw.com.br/ - -caps: - categorymappings: - - {id: 0, cat: TV/Anime, desc: "Selecione"} - - {id: 1, cat: TV/Anime, desc: "Completo"} - - {id: 2, cat: TV/Anime, desc: "Série"} - - {id: 3, cat: Movies, desc: "Filme"} - - {id: 4, cat: TV/Anime, desc: "OVA"} - - {id: 5, cat: Books, desc: "Manga"} - - {id: 6, cat: TV/Anime, desc: "Especial"} - - {id: 7, cat: TV/Anime, desc: "Promo"} - - {id: 8, cat: TV, desc: "Tokusatsu"} - - {id: 9, cat: TV, desc: "Live Action"} - - {id: 12, cat: TV, desc: "Dorama"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - method: form - path: entrar.php - submitpath: pagina_inicial.php?acao=login - form: form[action="pagina_inicial.php?acao=login"] - captcha: - type: image - selector: img[alt="Carregando captcha..."] - input: captcha - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: h1#h1_aviso - message: - selector: div#div_aviso > p - test: - path: pagina_inicial.php - selector: a[href="sair.php"] - -search: - paths: - - path: torrents.php - inputs: - palavra_chave: "{{ .Keywords }}" - # selecione (default), Com seeders (has seeders), Sem seeders (without seeders), Golden, Sem Golden (not Golden) - situacao: "{{ if .Config.freeleech }}golden{{ else }}selecione{{ end }}" - - rows: - selector: table#tabela_torrents > tbody > tr - - fields: - category: - selector: div.div_categoria_torrent > a - attribute: href - filters: - - name: querystring - args: categoria_id - title: - selector: a.link_torrent, a.link_golden_torrent - details: - selector: a.link_torrent, a.link_golden_torrent - attribute: href - download: - selector: a.link_download - attribute: href - date: - selector: td:nth-child(4) - filters: - - name: trim - - name: replace - args: ["h", ":"] - - name: re_replace - args: ["^(.{5})", "$1 "] - - name: append - args: " -03:00" # America/Sao_Paulo - - name: dateparse - args: "HH:mm dd/MM/yyyy zzz" - size: - selector: td[title="Tamanho"] - files: - selector: td[title="Quantidade de Arquivos"] > a - grabs: - selector: td[title="Quantos Completaram"] - seeders: - selector: td[title="Quantidade de Seeders"] > a - leechers: - selector: td[title="Quantidade de Leechers"] > a - genre: - selector: div.tags_da_linha_da_tabela - filters: - - name: regexp - args: "\\[(.+?)\\]" - description: - selector: td:nth-child(10) - downloadvolumefactor: - case: - span.icone_golden_torrent: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.6 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shanaproject.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shanaproject.yml deleted file mode 100644 index 472c54a4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shanaproject.yml +++ /dev/null @@ -1,150 +0,0 @@ ---- -id: shanaproject -name: Shana Project -description: "Shana Project is a Public torrent site for ANIME" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.shanaproject.com/ - -settings: - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - ep: episode - fsize: size - title: title - - name: type - type: select - label: Order requested from site - default: Descending - options: - Descending: desc - Ascending: asc - - name: info - type: info - label: Searching - default: This site only supports searching for Titles. Do not add a season or episode number, or a quality to your query. - -caps: - categorymappings: - - {id: Anime, cat: TV/Anime, desc: Anime} - - modes: - search: [q] - tv-search: [q, season, ep] - -search: - paths: - - path: search/ - inputs: - title: "{{ .Keywords }}" - subber: "" - sort: "{{ .Config.sort }}" - dir: "{{ .Config.type }}" - keywordsfilters: - # strip season and ep - - name: re_replace - args: ["(?i)(?:\\s?[SE]?\\d{1,4}){1,2}$", ""] - - rows: - selector: div.grid_12 > div[id^="rel"] - - fields: - category: - text: Anime - title: - selector: div.release_leftover > div.release_text_contents - filters: - - name: re_replace - args: ["^(\\[.*\\]) (.*)", "$2 $1"] - - name: re_replace - args: ["(?i)(.mkv)", ""] - details: - selector: a - attribute: href - download: - selector: a[href^="/download/"] - attribute: href - date_full: - # Dec. 8, 2022, 6:25 a.m. - selector: div.release_20:contains(".m."):contains(":") - optional: true - filters: - - name: replace - args: [".", ""] - - name: replace - args: ["Sept", "Sep"] - - name: replace - args: ["July", "Jul"] - - name: replace - args: ["June", "Jun"] - - name: re_replace - args: ["April", "Apr"] - - name: re_replace - args: ["March", "Mar"] - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM d, yyyy, h:mm tt zzz" - date_abbr: - # May 8, 2022, 6 a.m. - selector: div.release_20:contains(".m."):not(:contains(":")) - optional: true - filters: - - name: replace - args: [".", ""] - - name: replace - args: ["Sept", "Sep"] - - name: replace - args: ["July", "Jul"] - - name: replace - args: ["June", "Jun"] - - name: re_replace - args: ["April", "Apr"] - - name: re_replace - args: ["March", "Mar"] - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM d, yyyy, h tt zzz" - date_noon: - # Sept. 30, 2022, noon - selector: div.release_20:contains(", noon") - optional: true - filters: - - name: replace - args: [".", ""] - - name: replace - args: ["Sept", "Sep"] - - name: replace - args: ["July", "Jul"] - - name: replace - args: ["June", "Jun"] - - name: re_replace - args: ["April", "Apr"] - - name: re_replace - args: ["March", "Mar"] - - name: re_replace - args: [" noon", " 12 pm"] - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MMM d, yyyy, h tt zzz" - date: - text: "{{ if or .Result.date_full .Result.date_abbr .Result.date_noon }}{{ or .Result.date_full .Result.date_abbr .Result.date_noon }}{{ else }}now{{ end }}" - size: - selector: div.release_size - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shareisland-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shareisland-api.yml deleted file mode 100644 index 68e3ad88..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/shareisland-api.yml +++ /dev/null @@ -1,232 +0,0 @@ ---- -id: shareisland-api -name: Shareisland (API) -description: "Shareisland is a ITALIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: it-IT -type: private -encoding: UTF-8 -links: - - https://shareisland.org/ -legacylinks: - - http://shareisland.org/ - - http://www.shareisland.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movie"} - - {id: 28, cat: Movies, desc: "Foreign Movie"} - - {id: 2, cat: TV, desc: "Serie TV"} - - {id: 15, cat: Books/EBook, desc: "Ebook"} - - {id: 26, cat: Audio/Audiobook, desc: "Audio Book"} - - {id: 17, cat: Books/Mags, desc: "Riviste e Giornali"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 7, cat: PC/Games, desc: "Games"} - - {id: 23, cat: PC, desc: "Software"} - - {id: 24, cat: TV/Sport, desc: "Eventi Sportivi"} - - {id: 25, cat: Other, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Shareisland account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "After 60 days of no activity, your account will be automatically deleted." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["\\b([SE]\\d{1,4}){1,2}\\b", ""] - - name: diacritics - args: replace - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - filters: - - name: re_replace # replace special characters with " " (space) - args: ["[\\[!\"#$%&'()*+,\\/:;<=>?@\\^`{|}~]", " "] - - name: diacritics - args: replace - - name: re_replace # replace multiple spaces - args: ["[ ]{2,}", " "] - # normalize to SXXEYY format - - name: re_replace # S01 E01 to S01E01 - args: ["(?i)\\bS(\\d+)\\sE(\\d+)\\b", "S$1E$2"] - - name: re_replace # 01x01 to S01E01 - args: ["(?i)(\\d{2})x(\\d+)", "S$1E$2"] - - name: re_replace # 1x01 to S01E01 - args: ["(?i)\\b(\\d{1})x(\\d+)", "S0$1E$2"] - - name: re_replace # Stagione X --> S0X - args: ["(?i)\\bStagion[ei]\\s?(\\d{1})\\b|\\bSeason'?s?\\s?(\\d{1})\\b", "S0$1$2"] - - name: re_replace # Stagione XX --> SXX - args: ["(?i)\\bStagion[ei]\\s?(\\d{2,})\\b|\\bSeason'?s?\\s?(\\d{2,})\\b", "S$1$2"] - - name: re_replace # Episodio 4 to E4 - args: ["(?i)\\b(?:[\\/\\|]?Episodio\\s?(\\d+)|Puntata\\s?(\\d+))", "E$1$2"] - - name: re_replace # Episodi 4 5 to E04-05 - args: ["(?i)\\b(?:Puntate\\s*)(\\d+)\\s?(\\d+)", "E0$1-0$2"] - - name: re_replace # rimozioni varie - args: ["(?i)(Serie completa|Completat?a?|in pausa)", ""] - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(televisione film)", "televisione_film"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 6 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 6 days (as seconds = 6 x 24 x 60 x 60) - text: 518400 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sharewood-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sharewood-api.yml deleted file mode 100644 index 5018d463..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sharewood-api.yml +++ /dev/null @@ -1,203 +0,0 @@ ---- -id: sharewood-api -replaces: - - sharewoodapi -name: Sharewood (API) -description: "sharewood is a FRENCH Semi-Private Torrent Tracker for GENERAL" -language: fr-FR -type: semi-private -encoding: UTF-8 -requestDelay: 2.1 -links: - - https://www.sharewood.tv/ - -caps: - categorymappings: - # categories -# - {id: 1, cat: Movies, desc: "Vidéo"} -# - {id: 1, cat: TV, desc: "Vidéo"} -# - {id: 2, cat: Audio, desc: "Audio"} -# - {id: 3, cat: PC, desc: "Application"} -# - {id: 4, cat: Books/EBook, desc: "Ebooks"} -# - {id: 5, cat: PC/Games, desc: "Jeu-Vidéo"} -# - {id: 6, cat: Other, desc: "Formation"} -# - {id: 7, cat: XXX, desc: "XXX"} - - # subcategories - - {id: 9, cat: Movies, desc: "Films"} - - {id: 10, cat: TV, desc: "Séries"} - - {id: 11, cat: Movies/Other, desc: "Films Animations"} - - {id: 12, cat: TV/Anime, desc: "Séries Animations"} - - {id: 13, cat: TV/Documentary, desc: "Documentaires"} - - {id: 14, cat: TV/Other, desc: "Emissions TV"} - - {id: 15, cat: TV/Other, desc: "Spectacles/Concerts"} - - {id: 16, cat: TV/Sport, desc: "Sports"} - - {id: 17, cat: Audio/Video, desc: "Karaoké Vidéo"} - - {id: 18, cat: Audio/Other, desc: "Karaoké"} - - {id: 20, cat: Audio, desc: "Musiques"} - - {id: 21, cat: Audio/Other, desc: "Podcasts"} - - {id: 22, cat: Audio/Other, desc: "Samples"} - - {id: 23, cat: Audio/Audiobook, desc: "Ebooks Audio"} - - {id: 24, cat: Books/EBook, desc: "BDs"} - - {id: 25, cat: Books/Comics, desc: "Comics"} - - {id: 26, cat: Books/Other, desc: "Mangas"} - - {id: 27, cat: Books, desc: "Livres"} - - {id: 28, cat: Books/Mags, desc: "Presse"} - - {id: 29, cat: PC, desc: "Applications Linux"} - - {id: 30, cat: PC/0day, desc: "Applications Windows"} - - {id: 31, cat: PC/Mac, desc: "Applications Mac"} - - {id: 34, cat: PC/Mobile-iOS, desc: "Applications Smartphone/Tablette"} - - {id: 34, cat: PC/Mobile-Android, desc: "Applications Smartphone/Tablette"} - - {id: 35, cat: PC/Mobile-Other, desc: "GPS"} - - {id: 36, cat: PC/Games, desc: "Jeux Linux"} - - {id: 37, cat: PC/Games, desc: "Jeux Windows"} - - {id: 38, cat: PC/Mac, desc: "Jeux Mac"} - - {id: 39, cat: Console/NDS, desc: "Jeux Nintendo"} - - {id: 39, cat: Console/Wii, desc: "Jeux Nintendo"} - - {id: 39, cat: Console/Wiiware, desc: "Jeux Nintendo"} - - {id: 39, cat: Console/3DS, desc: "Jeux Nintendo"} - - {id: 39, cat: Console/WiiU, desc: "Jeux Nintendo"} - - {id: 40, cat: Console/PS4, desc: "Jeux Sony"} - - {id: 41, cat: PC/Mobile-Android, desc: "Jeux Smartphone/Tablette"} - - {id: 42, cat: PC/Games, desc: "Jeux Microsoft"} - - {id: 43, cat: Other, desc: "Rétrogaming & Emulation"} - - {id: 44, cat: XXX, desc: "Films XXX"} - - {id: 45, cat: XXX/Other, desc: "XXX Hentai"} - - {id: 47, cat: XXX/ImageSet, desc: "XXX Images"} - - {id: 48, cat: XXX/Other, desc: "XXX Jeux-Vidéo"} - - {id: 49, cat: Other/Misc, desc: "Formations Vidéos"} - - {id: 50, cat: Other/Misc, desc: "Formations Logiciels"} - - {id: 51, cat: XXX/Other, desc: "XXX Ebooks"} - - {id: 52, cat: Audio/Video, desc: "Vidéos-Clips"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: passkey - type: text - label: Passkey - - name: info_passkey - type: info - label: About your Passkey - default: "Find your Passkey by accessing your Sharewood profile on the My Profile page and scrolling down to the Passkey field." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -login: - path: "api/{{ .Config.passkey }}/last-torrents" - method: get - error: - - selector: ":root:contains(\"Invalid passkey\")" - - selector: ":root:contains(\"La page demandée est introuvable\")" # 404 page on empty passkey - - selector: ":root:contains(\"Passkey invalide\")" - - selector: ":root:contains(\"503 Service Temporarily Unavailable\")" - -search: - paths: - - path: "api/{{ .Config.passkey }}/{{ if .Keywords }}search{{ else }}last-torrents{{ end }}" - response: - type: json - - inputs: - subcategory: "{{ join .Categories \",\" }}" - name: "{{ .Keywords }}" - limit: 50 - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["[^\\w]+", " "] - - rows: - selector: $ - - fields: - _id: - selector: id - category: - selector: subcategory_id - title_phase1: - selector: name - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: slug - filters: - - name: prepend - args: "/torrents/" - - name: append - args: ".{{ .Result._id }}" - download: - selector: download_url - size: - selector: size - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - # api returns 0=false, 1=true - selector: free - case: - 0: 1 # not free - 1: 0 # freeleech - uploadvolumefactor: - # api returns 0=false, 1=true - selector: doubleup - case: - 0: 1 # normal - 1: 2 # double - minimumratio: - text: 0.75 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D v2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/showrss.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/showrss.yml deleted file mode 100644 index 4a02eb59..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/showrss.yml +++ /dev/null @@ -1,58 +0,0 @@ ---- -id: showrss-yml -name: showRSS -description: "showRSS is a service that allows you to keep track of your favorite TV shows" -language: en-US -type: public -encoding: UTF-8 -links: - - https://showrss.info/ - -caps: - categorymappings: - - {id: 1, cat: TV/SD} - - {id: 2, cat: TV/HD} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: [] - -search: - paths: - - path: /other/all.rss - response: - type: xml - - rows: - selector: rss > channel > item - filters: - - name: andmatch - - fields: - category: - selector: raw_title - case: - ":contains(\"720p\")": 2 - ":contains(\"1080p\")": 2 - "*": 1 - title: - selector: raw_title - details: - text: / - date: - selector: pubDate - download: - selector: link - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/siambit.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/siambit.yml deleted file mode 100644 index db97caff..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/siambit.yml +++ /dev/null @@ -1,223 +0,0 @@ ---- -id: siambit -name: SiamBIT -description: "SiamBIT is a THAI PAY2DL Private Torrent Tracker for GENERAL" -language: th-TH -type: private -encoding: tis-620 -requestDelay: 2 -links: - - https://bearbit.org/ -legacylinks: - - https://www.siambit.me/ - - https://siambit.io/ - - https://thaibit.net/ - - https://bearbit.co/ - - https://bearbit.cc/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 1, cat: Other, desc: "@พระมหากษัตริย์ (@King)", default: true} - - {id: 75, cat: PC/0day, desc: "OS วินโดว์/ออฟฟิศ (OS Windows/Office)", default: true} - - {id: 10, cat: TV/Anime, desc: "การ์ตูน/อนิเมชั่น (Cartoons/Animation)", default: true} - - {id: 9, cat: TV/Sport, desc: "กีฬา/ฟุตบอล (Sports/Football)", default: true} - - {id: 110, cat: PC/Games, desc: "เกมส์มือถือ (Mobile Games)", default: true} - - {id: 7, cat: PC/Games, desc: "เกมส์ (Games)", default: true} - - {id: 40, cat: Audio/Video, desc: "คลิป (Clips)", default: true} - - {id: 16, cat: Audio/Video, desc: "คอนเสิร์ต (Concert)", default: true} - - {id: 3, cat: Audio/Other, desc: "คาราโอเกะ/MV (Karaoke/MV)", default: true} - - {id: 70, cat: PC, desc: "ซอฟต์แวร์ AntiVirus/Antispyware (Software AntiVirus/Antispyware)", default: true} - - {id: 100, cat: PC/Mobile-Other, desc: "ซอฟต์แวร์ มือถือ/ธีม/ริงโทน/mv (Software Mobile/Theme/Ringtone/MV)", default: true} - - {id: 85, cat: PC/Mac, desc: "ซอฟต์แวร์ แม็ค (Mac Software)", default: true} - - {id: 105, cat: PC, desc: "ซอฟต์แวร์ ลินุกซ์ (Linux Software)", default: true} - - {id: 8, cat: PC, desc: "ซอฟต์แวร์ (Software)", default: true} - - {id: 56, cat: Other, desc: "ดิกชันเนอรี่/แผนที่ (Dictionary/Map)", default: true} - - {id: 19, cat: TV, desc: "ทอล์คโชว์/ตลก/วิทยุ (Talk Show/Comedy/Radio)", default: true} - - {id: 20, cat: TV, desc: "ทั่วไป (General)", default: true} - - {id: 55, cat: PC/Mobile-iOS, desc: "แท็ปเล็ต/iPhone (Tablet/iPhone)", default: true} - - {id: 21, cat: TV, desc: "ธรรมะ (Dharma)", default: true} - - {id: 2, cat: Audio/MP3, desc: "เพลง (Music)", default: true} - - {id: 130, cat: Audio/Audiobook, desc: "เพลงคุณภาพสูง (Lossless) (High Quality Music (Lossless))", default: true} - - {id: 60, cat: Other, desc: "ฟอนต์/ไอคอน/คลิปอาร์ท/เทมเพลท (Fonts/Icons/Clip Art/Templates)", default: true} - - {id: 90, cat: Movies/UHD, desc: "ภาพยนตร์ 4K UHD (4K UHD Movies)", default: true} - - {id: 91, cat: Movies/DVD, desc: "ภาพยนตร์ DVD (DVD Movies)", default: true} - - {id: 92, cat: Movies/HD, desc: "ภาพยนตร์ Hi-Def (Hi-Def Movies)", default: true} - - {id: 89, cat: Movies/SD, desc: "ภาพยนตร์ VCD (VCD Movies)", default: true} - - {id: 93, cat: TV, desc: "แม่และเด็ก@/สื่อเรียนรู้สำหรับเด็ก (Mother and Child@/Learning Materials for Children)", default: true} - - {id: 4, cat: TV, desc: "รายการทีวี/วาไรตี้/เกมโชว์ (TV Shows/Variety/Game Shows)", default: true} - - {id: 50, cat: Other, desc: "รูปภาพ (Images)", default: true} - - {id: 18, cat: Other, desc: "เว็บไซต์ (Website)", default: true} - - {id: 17, cat: TV/Documentary, desc: "สารคดี (Documentary)", default: true} - - {id: 11, cat: Books/Technical, desc: "สื่อเรียนรู้/หนังสือ/Ebook (Learning Materials/Books/Ebook)", default: true} - - {id: 5, cat: TV, desc: "หนังชุด/ซีรีส์ [เกาหลี] (Movies/Series [Korea])", default: true} - - {id: 54, cat: TV, desc: "หนังชุด/ซีรีส์ [จีน] (Movies/Series [China])", default: true} - - {id: 51, cat: TV, desc: "หนังชุด/ซีรีส์ [ญี่ปุ่น] (Movies/Series [Japan])", default: true} - - {id: 52, cat: TV, desc: "หนังชุด/ซีรีส์ [ฝรั่ง] (Movies/Series [Western])", default: true} - - {id: 57, cat: TV, desc: "หนังชุด/ซีรีส์ [อื่นๆ] (Movies/Series [other])", default: true} - - {id: 53, cat: TV, desc: "หนังชุด/ซีรีส์/ละคร [ไทย] (Movie Series/Series/Drama [Thai])", default: true} - - {id: 901, cat: XXX, desc: "XXX-Anime", default: false} - - {id: 902, cat: XXX, desc: "XXX-Game", default: false} - - {id: 903, cat: XXX, desc: "XXX-Japan censored", default: false} - - {id: 904, cat: XXX, desc: "XXX-Japan", default: false} - - {id: 905, cat: XXX, desc: "XXX-Guava", default: false} - - {id: 906, cat: XXX, desc: "XXX-Asia censored", default: false} - - {id: 907, cat: XXX, desc: "XXX-Asia", default: false} - - {id: 908, cat: XXX, desc: "XXX-Gay", default: false} - - {id: 910, cat: XXX, desc: "XXX-clip", default: false} - - {id: 911, cat: XXX, desc: "XXX-pictures", default: false} - - {id: 912, cat: XXX, desc: "XXX-magazine", default: false} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 15 - options: - 15: created - 8: seeders - 5: size - 1: title desc - 14: title asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then the most likely reason is that you have Important news from the team on the Web Site that must be read before you can proceed to the torrent search page." - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts can use the park system for up to 180 days." - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.text - test: - path: / - selector: a[href="logout.php"] - -download: - before: - path: ajax.php - method: get - inputs: - action: "say_thanks" - id: "{{ .DownloadUri.Query.id }}" - selectors: - - selector: a[href^="downloadnew.php?id="] - attribute: href - -search: - paths: - - path: viewno18sbx.php - categories: [1, 75, 10, 9, 110, 7, 40, 16, 3, 70, 100, 85, 105, 8, 56, 19, 20, 55, 21, 2, 130, 60, 90, 91, 92, 89, 93, 4, 50, 18, 17, 11, 5, 54, 51, 52, 57, 53] - - path: viewbrsb.php - categories: [901, 902, 903, 904, 905, 906, 907, 908, 910, 911, 912] - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 both, 2 descr - searchin: 0 - sortby: "{{ .Config.sort }}" - - rows: - selector: "table[width=\"100%\"][border=1][cellspacing=0][cellpadding=5] > tbody > tr:has(a[href*=\"hashinfo=\"]){{ if .Config.freeleech }}:has(td:nth-child(3):contains(\"100%\")){{ else }}{{ end }}" - - fields: - category: - selector: a[href*=".php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - poster: - selector: img[alt="Poster"] - attribute: src - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - files: - selector: td:nth-child(6) - date: - selector: td:nth-child(8) - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "dd-MM-yyyyHH:mm:ss zzz" - size: - selector: td:nth-child(9) - grabs: - selector: td:nth-child(10) - seeders: - selector: td:nth-child(11) - leechers: - selector: td:nth-child(12) - downloadvolumefactor: - case: - "td:nth-child(4):contains(\"100%\")": 0 - "td:nth-child(4):contains(\"95%\")": 0.05 - "td:nth-child(4):contains(\"90%\")": 0.1 - "td:nth-child(4):contains(\"85%\")": 0.15 - "td:nth-child(4):contains(\"80%\")": 0.2 - "td:nth-child(4):contains(\"75%\")": 0.25 - "td:nth-child(4):contains(\"70%\")": 0.3 - "td:nth-child(4):contains(\"65%\")": 0.35 - "td:nth-child(4):contains(\"60%\")": 0.4 - "td:nth-child(4):contains(\"55%\")": 0.45 - "td:nth-child(4):contains(\"50%\")": 0.5 - "td:nth-child(4):contains(\"45%\")": 0.55 - "td:nth-child(4):contains(\"40%\")": 0.6 - "td:nth-child(4):contains(\"35%\")": 0.65 - "td:nth-child(4):contains(\"30%\")": 0.7 - "td:nth-child(4):contains(\"25%\")": 0.75 - "td:nth-child(4):contains(\"20%\")": 0.8 - "td:nth-child(4):contains(\"15%\")": 0.85 - "td:nth-child(4):contains(\"10%\")": 0.9 - "td:nth-child(4):contains(\"5%\")": 0.95 - "*": 1 - uploadvolumefactor: - case: - "td:nth-child(5):contains(\"x2\")": 2 - "td:nth-child(5):contains(\"x3\")": 3 - "td:nth-child(5):contains(\"x4\")": 4 - "td:nth-child(5):contains(\"x5\")": 5 - "td:nth-child(5):contains(\"x6\")": 6 - "td:nth-child(5):contains(\"x7\")": 7 - "td:nth-child(5):contains(\"x8\")": 8 - "td:nth-child(5):contains(\"x9\")": 9 - "*": 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/siqi.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/siqi.yml deleted file mode 100644 index 46381459..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/siqi.yml +++ /dev/null @@ -1,171 +0,0 @@ ---- -id: siqi -name: Siqi -description: "Siqi is a CHINESE Private Tracker for HD image BOOKS and E-BOOKS" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://si-qi.xyz/ - -caps: - categorymappings: - - {id: 411, cat: Books/Comics, desc: "漫画 (Comics)"} - - {id: 417, cat: Books/Technical, desc: "软件 (Software)"} - - {id: 416, cat: Books/EBook, desc: "摄影 (Photography)"} - - {id: 415, cat: Books/Technical, desc: "工具书 (Reference Books)"} - - {id: 414, cat: Books/Mags, desc: "杂志 (Magazines)"} - - {id: 413, cat: Other, desc: "图片集 (Photo Albums)"} - - {id: 412, cat: Books/Comics, desc: "连环画 (Comic Strips)"} - - {id: 418, cat: Books/EBook, desc: "插图书 (Illustrated Books)"} - - {id: 401, cat: Books/Technical, desc: "教学 (Instructional)"} - - {id: 409, cat: Books/Other, desc: "其他 (Others)"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: 0 - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 days (as seconds = 1 x 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 (Custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/skidrowrepack.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/skidrowrepack.yml deleted file mode 100644 index a6c7f9e8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/skidrowrepack.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -id: skidrowrepack -name: SkidrowRepack -description: "SkidrowRepack is a Public Torrent Tracker for GAMES" -language: en-US -type: public -encoding: UTF-8 -links: - - https://skidrowrepack.com/ - -caps: - categorymappings: - - {id: 9, cat: PC/Games, desc: "Action"} - - {id: 10, cat: PC/Games, desc: "Adventure"} - - {id: 21, cat: PC/Games, desc: "Online Games"} - - {id: 11, cat: PC/Games, desc: "Arcade"} - - {id: 12, cat: PC/Games, desc: "Fighting"} - - {id: 13, cat: PC/Games, desc: "Horror"} - - {id: 14, cat: PC/Games, desc: "Logic, Puzzle"} - - {id: 15, cat: PC/Games, desc: "Racing"} - - {id: 16, cat: PC/Games, desc: "RPG"} - - {id: 17, cat: PC/Games, desc: "Shooters"} - - {id: 18, cat: PC/Games, desc: "Simulator"} - - {id: 19, cat: PC/Games, desc: "Sports"} - - {id: 20, cat: PC/Games, desc: "Strategy"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a.dwn-load - attribute: href - -search: - paths: - - path: index.php - inputs: - $raw: "{{ range .Categories }}catlist[]={{.}}&{{end}}" - do: search - subaction: search - search_start: 0 - full_search: 1 - result_from: 1 - showposts: 1 - # 0 article, 1 comments, 2 static pages, 3 article titles - titleonly: "{{ if .Keywords }}3{{ else }}0{{ end }}" - searchdate: 0 - story: "{{ if .Keywords }}{{ .Keywords }}{{ else }}game{{ end }}" - sortby: date - resorder: desc - - rows: - selector: div.mov - - fields: - category: - text: 9 - title: - selector: a.mov-t - details: - selector: a.mov-t - attribute: href - download: - selector: a.mov-t - attribute: href - poster: - selector: img - attribute: src - size: - text: "4 GB" - date: - text: now - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/skipthecommercials-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/skipthecommercials-api.yml deleted file mode 100644 index de37cdc0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/skipthecommercials-api.yml +++ /dev/null @@ -1,189 +0,0 @@ ---- -id: skipthecommercials-api -name: SkipTheCommercials (API) -description: "SkipTheCommercials (STC) is a Private Torrent Tracker for TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://skipthecommercials.xyz/ -legacylinks: - - https://skipthecommericals.xyz/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV Show"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your SkipTheCommercials account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must be seeding at least 1 torrent or login once every 90 days to not get pruned. Once pruned, you will get additional 30 days to reactivate your account by logging in." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sktorrent-org.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sktorrent-org.yml deleted file mode 100644 index 970212d3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sktorrent-org.yml +++ /dev/null @@ -1,158 +0,0 @@ ---- -id: sktorrent-org -name: SkTorrent.org -description: "SkTorrent.org is a Semi-Private torrent site for MOVIES / TV/ GENERAL" -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://sktorrent.org/ - -caps: - categorymappings: - - {id: 18, cat: PC/0day, desc: "Application - PC"} - - {id: 19, cat: PC/Mac, desc: "Application - Mac"} - - {id: 21, cat: PC/Mobile-Other, desc: "Application - Other"} - - {id: 47, cat: PC/Mobile-Android, desc: "Application - Android"} - - {id: 61, cat: Audio/Audiobook, desc: "Audio - Audio books"} - - {id: 60, cat: Audio, desc: "Audio - Music"} - - {id: 63, cat: Audio/Lossless, desc: "Audio - Flac"} - - {id: 64, cat: Audio/Other, desc: "Audio - Other"} - - {id: 9, cat: TV/Documentary, desc: "Documentaries - Other"} - - {id: 10, cat: PC/Games, desc: "Games - PC"} - - {id: 11, cat: Console/PS3, desc: "Games - PSX"} - - {id: 13, cat: Console/XBox 360, desc: "Games - Xbox360"} - - {id: 66, cat: Console/Other, desc: "Games - Other"} - - {id: 65, cat: Console/Other, desc: "Games - Android"} - - {id: 1, cat: Movies/DVD, desc: "Movies - DVD"} - - {id: 2, cat: Movies/SD, desc: "Movies - Divx/Xvid"} - - {id: 42, cat: Movies/HD, desc: "Movies - HD"} - - {id: 4, cat: Movies/Other, desc: "Movies - Other"} - - {id: 49, cat: Movies/3D, desc: "Movies - 3D"} - - {id: 48, cat: Movies/BluRay, desc: "Movies - Blu-ray"} - - {id: 70, cat: Movies/UHD, desc: "Movies - 4K"} - - {id: 22, cat: Audio/MP3, desc: "Music - MP3"} - - {id: 24, cat: Audio/Video, desc: "Music - DVD"} - - {id: 27, cat: Audio/Other, desc: "Music - Other"} - - {id: 35, cat: Audio, desc: "Music - Album"} - - {id: 36, cat: Books/EBook, desc: "Other - E-books"} - - {id: 53, cat: Other, desc: "Other - Other"} - - {id: 51, cat: Books/Mags, desc: "Other - Magazines"} - - {id: 50, cat: Books, desc: "Other - Books"} - - {id: 56, cat: XXX/WMV, desc: "Porn - Video"} - - {id: 55, cat: XXX, desc: "Porn - Movie"} - - {id: 54, cat: XXX/ImageSet, desc: "Porn - Picture"} - - {id: 68, cat: XXX/Other, desc: "Porn - Other"} - - {id: 69, cat: XXX/ImageSet, desc: "All - All"} - - {id: 6, cat: TV, desc: "TV - Divx/Xvid"} - - {id: 41, cat: TV/HD, desc: "TV - HD"} - - {id: 59, cat: TV/HD, desc: "TV - Full HD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: account-login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.myFrame:contains("Access Denied") - test: - path: index.php - selector: a[href="account-logout.php"] - -search: - paths: - # https://sktorrent.org/torrents-search.php?c48=1&c2=1&c1=1&c42=1&c4=1&search=&cat=0&incldead=1&freeleech=0&inclexternal=0&lang=0 - # https://sktorrent.org/torrents-search.php?search=&cat=0&incldead=0&freeleech=0&inclexternal=0&lang=0 - - path: torrents-search.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - cat: 0 - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - # 0 all, 1 notfree, 2 onlyfree - freeleech: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 both, 1 local, 2 external - inclexternal: 0 - # 0 all, 1 English, 2 etc... - lang: 0 - sort: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - keywordsfilters: - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: tr.t-row:has(a[href^="download.php?id="]) - filters: - - name: andmatch - - fields: - category: - selector: td a[href^="torrents.php?cat="] - optional: true - default: 53 - attribute: href - filters: - - name: querystring - args: cat - title: - selector: td a[href^="torrents-details.php?id="] - attribute: title - details: - selector: td a[href^="torrents-details.php?id="] - attribute: href - download: - selector: td a[href^="download.php?id="] - attribute: href - date: - text: now - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# TorrentTrader diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sktorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sktorrent.yml deleted file mode 100644 index 338cbdb3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sktorrent.yml +++ /dev/null @@ -1,239 +0,0 @@ ---- -id: sktorrent -name: Sk-CzTorrent -description: "Sk-CzTorrent is a CZECH/SLOVAK Semi-Private Torrent Tracker for MOVIES / TV / GENERAL" -language: cs-CZ -type: semi-private -encoding: UTF-8 -links: - - https://sktorrent.eu/ -legacylinks: - - http://sktorrent.eu/torrent/ - - http://sktorrent.eu/ - -caps: - categorymappings: - - {id: 9, cat: XXX, desc: "xXx"} - - {id: 23, cat: Books, desc: "Knihy a Časopisy"} - - {id: 25, cat: Other, desc: "Ostatní"} - - {id: 1, cat: Movies, desc: "Filmy CZ/SK dabing"} - - {id: 5, cat: Movies, desc: "Filmy Kreslené"} - - {id: 14, cat: Movies/Other, desc: "Filmy Kamera"} - - {id: 15, cat: Movies, desc: "Filmy s titulkama"} - - {id: 20, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 31, cat: Movies, desc: "Filmy bez titulků"} - - {id: 3, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 19, cat: Movies/HD, desc: "Filmy HD"} - - {id: 28, cat: Movies/BluRay, desc: "Filmy Blu-ray"} - - {id: 29, cat: Movies/3D, desc: "Filmy 3D Blu-ray"} - - {id: 43, cat: Movies/UHD, desc: "Filmy UHD"} - - {id: 18, cat: PC/Games, desc: "Hry na Windows"} - - {id: 30, cat: PC/Games, desc: "Hry na Konzole"} - - {id: 37, cat: PC/Games, desc: "Hry na Linux"} - - {id: 59, cat: PC/Games, desc: "Hry na Mac"} - - {id: 60, cat: PC/Games, desc: "xXx hry (18+)"} - - {id: 63, cat: PC/Games, desc: "VR Hry"} - - {id: 16, cat: TV, desc: "TV Seriál"} - - {id: 17, cat: TV/Documentary, desc: "TV Dokument"} - - {id: 42, cat: TV, desc: "TV Pořad"} - - {id: 44, cat: TV/Sport, desc: "TV Sport"} - - {id: 2, cat: Audio, desc: "Hudba"} - - {id: 22, cat: Audio, desc: "Hudba DJ's Mix"} - - {id: 24, cat: Audio, desc: "Mluvené slovo"} - - {id: 26, cat: Audio/Video, desc: "Hudební videa"} - - {id: 45, cat: Audio, desc: "Soundtrack"} - - {id: 21, cat: PC, desc: "Programy"} - - {id: 27, cat: PC/Mobile-Other, desc: "Mobil, PDA"} - - {id: 58, cat: Other, desc: "Neschválené"} - - modes: - search: [q] - tv-search: [q, season, ep, genre] - movie-search: [q, genre] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - -login: - path: torrent/login.php - method: form - form: form[action^="login.php?returnto=index.php"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: font:contains("Incorrect") - test: - path: torrent/index.php - selector: a[href^="usercp.php"] - -search: - paths: - - path: torrent/torrents.php - inputs: - search: "{{ .Keywords }}" - category: "{{ range .Categories }}{{.}};{{end}}" - # 0 all, 1 activeonly, 2 deadonly - active: 0 - zaner: "{{ .Query.Genre }}" - keywordsfilters: - - name: diacritics - args: replace - - rows: - selector: table.lista > tbody > tr > td > table.lista > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="torrents.php?category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href^="details.php?id="] - attribute: title - filters: - - name: re_replace - args: ["^VA\\s*\\|", "VA -"] - - name: re_replace - args: ["^.*? \\/\\s*|^.*? \\|\\s*", ""] - - name: re_replace - args: ["\\|\\s*\\d+\\% CSFD\\.cz\\/|\\s*=*\\s*CSFD\\s*\\d+\\%|\\s*CSFD\\s*=*\\s*\\d+\\%|\\s*CSFD", ""] - - name: re_replace - args: ["(\\d{1})\\s*\\.*\\s*az*\\s*(\\d{2,})\\s*\\.*\\s(?i)serie\\.*", "S0$1-S$2"] - - name: re_replace - args: ["(\\d{1})\\s*\\.*\\s*az*\\s*(\\d{1})\\s*\\.*\\s(?i)serie\\.*", "S0$1-S$2"] - - name: re_replace - args: ["(?i)serie\\s*(\\d{1})\\s*\\.*\\s*az*\\s*(\\d{2,})\\s*\\.*", "S0$1-S$2"] - - name: re_replace - args: ["(?i)serie\\s*(\\d{1})\\s*\\.*\\s*az*\\s*(\\d{1})\\s*\\.*", "S0$1-S$2"] - - name: re_replace - args: ["(?i)season\\,*\\s*(\\d{1})\\,\\s*\\d{1}\\,\\s*(\\d{2,})", "S0$1-S$2"] - - name: re_replace - args: ["(?i)season\\,*\\s*(\\d{1})\\,\\s*\\d{1}\\,\\s*(\\d{1})", "S0$1-S0$2"] - - name: re_replace - args: ["(?i)season\\s*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{2,})\\s*\\.*", "S0$1-S$2"] - - name: re_replace - args: ["(?i)season\\s*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{1})\\s*\\.*", "S0$1-S0$2"] - - name: re_replace - args: ["^GRID\\:*\\s(?i)season", "GRID Seasson"] - - name: re_replace - args: ["(?i)season\\s*(\\d{2,})\\.*", "S$1"] - - name: re_replace - args: ["(?i)season\\s*(\\d{1})\\.*", "S0$1"] - - name: re_replace - args: ["GRID Seasson", "GRID Season"] - - name: re_replace - args: ["\\sPES\\s(\\d{4})\\s(?i)season", " PES $1 Seasson"] - - name: re_replace - args: ["\\s\\(*(\\d{2,})\\.*\\s*(?i)season\\)*", " S$1"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{2,})\\.*\\s(?i)season\\)*", " S0$1-S$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{1})\\.*\\s(?i)season\\)*", " S0$1-S0$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\.*\\s*(?i)season\\)*", " S0$1"] - - name: re_replace - args: [" PES (\\d{4}) Seasson", " PES $1 Season"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\,\\s*\\d{1}\\,\\s*(\\d{2,})\\,*\\s*(?i)serie\\)*", " S0$1-S$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\,\\s*\\d{1}\\,\\s*(\\d{1})\\,*\\s*(?i)serie\\)*", " S0$1-S0$2"] - - name: re_replace - args: ["\\s\\(*(\\d{2,})\\.*\\s*(?i)serie\\)*", " S$1"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{2,})\\.*\\s(?i)serie\\)*", " S0$1-S$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{1})\\.*\\s(?i)serie\\)*", " S0$1-S0$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\.*\\s*(?i)serie\\)*", " S0$1"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\,\\s*\\d{1}\\,\\s*(\\d{2,})\\,*\\s*(?i)seria\\)*", " S0$1-S$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\,\\s*\\d{1}\\,\\s*(\\d{1})\\,*\\s*(?i)seria\\)*", " S0$1-S0$2"] - - name: re_replace - args: ["\\s\\d{2,}\\.*\\s*(?i)seria\\s\\((?i)s(\\d{2,})", " (S$1"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{2,})\\.*\\s(?i)seria\\)*", " S0$1-S$2"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\s*\\.*\\-*\\,*\\+*\\s*(\\d{1})\\.*\\s(?i)seria\\)*", " S0$1-S0$2"] - - name: re_replace - args: ["\\s\\(*(\\d{2,})\\.*\\s*(?i)seria\\)*", " S$1"] - - name: re_replace - args: ["\\s\\(*(\\d{1})\\.*\\s*(?i)seria\\)*", " S0$1"] - - name: re_replace - args: ["\\s\\d{1}\\.*\\s*(?i)seria\\s*\\((?i)s(\\d{1})", " (S0$1"] - - name: re_replace - args: ["(\\d{2,})\\.*\\s*(?i)serii\\)*", "S01-S$1"] - - name: re_replace - args: ["(\\d{1})\\.*\\s*(?i)serii\\)*", "S01-S0$1"] - - name: re_replace - args: ["(?i)pouze (\\d{1})\\-(\\d{2,})", "pouze S0$1-S$2"] - - name: re_replace - args: ["(?i)pouze (\\d{1})\\-(\\d{1})", "pouze S0$1-S0$2"] - - name: re_replace - args: ["(?i)komplet (\\d{1})\\-(\\d{2,})", "komplet S0$1-S$2"] - - name: re_replace - args: ["(?i)komplet (\\d{1})\\-(\\d{1})", "komplet S0$1-S0$2"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: a[href^="#"] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) width" - - name: prepend - args: "https:" - size: - selector: td:nth-child(3) - filters: - - name: regexp - args: "Velkost (.+?) \\|" - date: - selector: td:nth-child(3) - filters: - - name: regexp - args: "Pridany (\\d{2}/\\d{2}/\\d{4} o \\d{2}:\\d{2})" - - name: replace - args: [" o ", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd/MM/yyyy HH:mm zzz" - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - genre: - selector: td:nth-child(3) - filters: - - name: replace - args: ["\n", ""] - - name: replace - args: ["\x09", " "] - - name: validate - args: "Akcni, Animovany, Dobrodruzny, Dokumentarny, Drama, Eroticky, Experimentalni, Fantasy, Historicky, Horor, Hudebni, Katastroficky, Komedie, Kratkometrazni, Krimi, Loutkovy, Muzikal, Mysteriozni, Pohadka, Povidkovy, Psychologicky, Publicisticky, Reality-TV, Road-Movie, Rodinny, Romanticky, Sci-Fi, Soutezni, Sportovni, Talk-show, Telenovela, Thriller, Valecny, Western, Zivotopisny" - description: - text: "{{ .Result.genre }}" - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.51 -# BtiTracker 1.4.8 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/slobitmedia-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/slobitmedia-api.yml deleted file mode 100644 index 42f339d7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/slobitmedia-api.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: slobitmedia-api -name: Slobit Media (API) -description: "Slobit Media is a SLOVENIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://media.slo-bitcloud.eu/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 4, cat: Console, desc: "Games"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 5, cat: Other, desc: "Other"} - - {id: 6, cat: PC, desc: "Programm"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Slobit Media account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumratio: - text: 1.0 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/snowpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/snowpt.yml deleted file mode 100644 index 31be406a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/snowpt.yml +++ /dev/null @@ -1,113 +0,0 @@ ---- -id: snowpt -name: SnowPT -description: "SnowPT (SSPT) is a CHINESE Private Torrent Tracker for ANIME" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://skyeysnow.com/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - -login: - path: login.php - method: form - form: form[id^="loginform_"] - inputs: - referer: / - loginfield: username - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - cookietime: 2592000 - error: - - selector: div#messagetext:contains("登录失败") p - test: - path: / - selector: a[href*="action=logout&formhash="] - -search: - paths: - # https://skyeysnow.com/forum.php?mod=torrents&cat=1&search=sei+no+kakuritsu - - path: forum.php - inputs: - mod: torrents - cat: 1 - search: "{{ .Keywords }}" - - rows: - selector: "table.torrents > tbody > tr:has(a[href^=\"/download.php?id=\"]){{ if .Config.freeleech }}:has(img.sp_4){{ else }}{{ end }}" - - fields: - category: - text: 1 - title_default: - selector: a[href^="/forum.php?mod=viewthread&tid="] - title: - selector: a[title][href^="/forum.php?mod=viewthread&tid="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="/forum.php?mod=viewthread&tid="] - attribute: href - download: - selector: a[href^="/download.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(3) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(3):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(4) - seeders: - selector: td:nth-child(5) - leechers: - selector: td:nth-child(6) - grabs: - selector: td:nth-child(7) - downloadvolumefactor: - case: - img.sp_4: 0 # FREE - img.sp_2: 0.5 # 50% - "*": 1 - uploadvolumefactor: - case: -# img.sp_0: 2 # do not know what a flag of 2xSFC means, but I dont think this is a 2xupload - "*": 1 -# Discuz! X3.4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sosulki.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sosulki.yml deleted file mode 100644 index 4b2e75dd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sosulki.yml +++ /dev/null @@ -1,85 +0,0 @@ ---- -id: sosulki -name: sosulki -description: "sosulki is a RUSSIAN Public Torrent Tracker for 3X" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://sosulki.hlom.ru/ -legacylinks: - - http://sosulki.net/ - - http://sosulki.com/ # site forces https - - https://sosulki.com/ -caps: - categorymappings: - - {id: 12, cat: XXX, desc: "Порно категории"} - - {id: 77, cat: XXX, desc: "Порно фильмы русские"} - - {id: 78, cat: XXX, desc: "Транссексуалы (Transsexual)"} - - {id: 79, cat: XXX, desc: "Бисексуалы (Bisexual)"} - - {id: 80, cat: XXX, desc: "Большие Члены (Big Cocks)"} - - {id: 81, cat: XXX, desc: "Секс Машины (Fucking Machines)"} - - {id: 75, cat: XXX, desc: "Порно фильмы зарубежные"} - - {id: 456, cat: XXX, desc: "Порно фильмы зарубежные"} - - {id: 76, cat: XXX, desc: "Геи (Gay)"} - - {id: 70, cat: XXX, desc: "БДСМ (BDSM)"} - - {id: 67, cat: XXX, desc: "Анал (Anal)"} - - {id: 68, cat: XXX, desc: "Большие попки (Big Ass)"} - - {id: 69, cat: XXX, desc: "Большие сиськи (Big Tits)"} - - {id: 73, cat: XXX, desc: "Групповой секс (Group Sex)"} - - {id: 65, cat: XXX, desc: "Порноролики зарубежные "} - - {id: 66, cat: XXX, desc: "Русское Порно Любительское (Amateur Russian)"} - - {id: 71, cat: XXX, desc: "Лесбиянки (Lesbians)"} - - {id: 74, cat: XXX, desc: "Любительское порно фото (Amateur Picture Gallery)"} - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}?do=search&subaction=search&search_start=0&full_search=1&result_from=1&story={{ .Keywords }}&titleonly=3&catlist[]=0&searchdate=0&resorder=desc{{ else }}lastnews/{{ end }}" - - rows: - selector: tr.fr_viewn_in - - fields: - category: - selector: img[src^="/templates/freewhite/img/"] - attribute: src - filters: - - name: regexp - args: (\d+) - title: - selector: td.titlelast a - attribute: title - details: - selector: td.titlelast a - attribute: href - download: - selector: td.titlelast a - attribute: href - date: - selector: i - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "dd-MM-yyyy HH:mm:ss zzz" - size: - selector: td.frs - seeders: - selector: td.frsl_s - leechers: - selector: td.frsl_p - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# DataLife diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/soulvoice.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/soulvoice.yml deleted file mode 100644 index d9cd3f97..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/soulvoice.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -id: soulvoice -name: SoulVoice (聆音Club) -description: "SoulVoice (聆音Club) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.soulvoice.club/ - -caps: - categorymappings: - - {id: 407, cat: Other, desc: "Study/教学"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 403, cat: TV, desc: "TV Show/综艺"} - - {id: 406, cat: Audio/Video, desc: "MusicVideo/音乐视频"} - - {id: 408, cat: Audio, desc: "HQ Audio/音乐"} - - {id: 409, cat: Other, desc: "其它 (Other)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the SoulVoice Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran Users users and above will be retained forever
  2. Elite Users and above will not have their account deleted after parking (in the Control Panel)
  3. Users with a parked account will be deleted if they do not log in for 180 consecutive days
  4. Users with a non-parked account will be banned if they do not log in for 60 consecutive days
  5. Users with no traffic (ie, upload/download data are both 0) will be banned if they do not log in for 7 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-last-of-type(6) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-last-of-type(6):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-last-of-type(5) - seeders: - selector: td.rowfollow:nth-last-of-type(4) - leechers: - selector: td.rowfollow:nth-last-of-type(3) - grabs: - selector: td.rowfollow:nth-last-of-type(2) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.8 2023-09-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/speedmasterhd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/speedmasterhd.yml deleted file mode 100644 index f8cbc8ad..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/speedmasterhd.yml +++ /dev/null @@ -1,222 +0,0 @@ ---- -id: speedmasterhd -name: Speedmaster HD -description: "Speedmaster HD is a GERMAN Time based Torrent Tracker for MOVIES / TV" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://speedmaster-hd.net/ - -caps: - categorymappings: - # 1080p - - {id: 131, cat: Movies/HD, desc: "Movies 1080p"} - - {id: 133, cat: Movies/HD, desc: "Movie Pack 1080p"} - - {id: 29, cat: TV/HD, desc: "Serien 1080p"} - - {id: 17, cat: TV/Documentary, desc: "Doku 1080p"} - - {id: 18, cat: XXX, desc: "XXX 1080p"} - - {id: 95, cat: TV/HD, desc: "Serien Packs 1080p"} - - {id: 138, cat: Movies/HD, desc: "x265 1080p"} - # 720p - - {id: 60, cat: Movies/HD, desc: "Movies 720p"} - - {id: 132, cat: Movies/HD, desc: "Movie Pack 720p"} - - {id: 30, cat: TV/HD, desc: "Serien 720p"} - - {id: 21, cat: TV/Documentary, desc: "Doku 720p"} - - {id: 22, cat: XXX, desc: "XXX 720p"} - - {id: 96, cat: TV/HD, desc: "Serien Packs 720p"} - - {id: 139, cat: Movies/HD, desc: "x265 720p"} - # 4K/2k - - {id: 129, cat: Movies/UHD, desc: "Remux 4K/2k"} - - {id: 130, cat: Movies/UHD, desc: "Untouched 4K/2k"} - - {id: 134, cat: Movies/UHD, desc: "UHD/2160p 4K/2k"} - - {id: 120, cat: Movies/UHD, desc: "Movies 4K/2k"} - - {id: 121, cat: TV/UHD, desc: "Serien 4K/2k"} - - {id: 122, cat: TV/Documentary, desc: "Doku 4K/2k"} - - {id: 123, cat: XXX, desc: "XXX 4K/2k"} - # 3D - - {id: 90, cat: Movies/3D, desc: "Untouched 3D"} - - {id: 87, cat: Movies/3D, desc: "1080p 3D"} - - {id: 92, cat: Movies/3D, desc: "720p 3D"} - - {id: 118, cat: TV/Documentary, desc: "Doku 3D"} - - {id: 97, cat: XXX, desc: "XXX 3D"} - # Audio - - {id: 135, cat: Audio/Audiobook, desc: "Audio A-Book/FLAC"} - - {id: 136, cat: Audio/Audiobook, desc: "Audio A-Book/MP3"} - - {id: 33, cat: Audio/Lossless, desc: "Audio Lossless"} - - {id: 124, cat: Audio/MP3, desc: "Audio Lossy"} - - {id: 125, cat: Audio, desc: "Audio Tonspur"} - # MusikVideo - - {id: 42, cat: Audio/Video, desc: "MusikVideo Untouched"} - - {id: 101, cat: Audio/Video, desc: "MusikVideo Remux"} - - {id: 43, cat: Audio/Video, desc: "MusikVideo 1080p"} - - {id: 44, cat: Audio/Video, desc: "MusikVideo 720p"} - # Untouched - - {id: 51, cat: Movies, desc: "Movies Untouched"} - - {id: 100, cat: TV, desc: "Serien Untouched"} - - {id: 99, cat: TV/Documentary, desc: "Doku Untouched"} - - {id: 102, cat: XXX, desc: "XXX Untouched"} - # Remux - - {id: 12, cat: Movies, desc: "Movies Remux"} - - {id: 126, cat: TV, desc: "Serien Remux"} - - {id: 11, cat: TV/Documentary, desc: "Doku Remux"} - - {id: 10, cat: XXX, desc: "XXX Remux"} - # Sonstiges - - {id: 49, cat: Other, desc: "Sonstiges RarFix"} - - {id: 93, cat: Other, desc: "Sonstiges HD-Pics"} - - {id: 94, cat: Other, desc: "Sonstiges HD-XXX Pics"} - - {id: 98, cat: Other, desc: "Sonstiges to sort"} - # E-Book - - {id: 103, cat: Books/EBook, desc: "E-Book Mixed"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form[action="login.php"] - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - input[id="username"]: "{{ .Config.username }}" - input[id="password"]: "{{ .Config.password }}" - input[name="loginkeeping"]: loginkeeping - input[name="loginnow"]: yes - selectors: true - selectorinputs: - uufl: - selector: input[name="uufl"] - attribute: value - uupf: - selector: input[name="uupf"] - attribute: value - error: - - selector: "div[style^=\"color: #EC0000;\"]" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both - blah: "0" - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: div.content_wrap > div.selection_wrap:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="selection.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download_ssl.php?torrent="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: div[id^="details-"] div div img - attribute: src - filters: - - name: replace - args: ["cloud-data/standard_cover/ebook_t.png", ""] - - name: replace - args: ["cloud-data/standard_cover/doku_t.png", ""] - - name: replace - args: ["cloud-data/standard_cover/serie_t.png", ""] - - name: replace - args: ["cloud-data/standard_cover/music_t.png", ""] - - name: replace - args: ["cloud-data/standard_cover/no_pic_t.png", ""] - - name: replace - args: ["cloud-data/standard_cover/xxx_t.png", ""] - description: - selector: div[class^="flag_pic_"] - attribute: onmouseover - filters: - - name: regexp - args: "'(.+?)'" - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - date_day: - # Heute 03:27:27 - # Gestern 21:00:59 - selector: div.selection_unter_ab:contains("Heute"), div.selection_unter_ab:contains("Gestern") - optional: true - filters: - - name: replace - args: ["Heute ", "Today"] - - name: replace - args: ["Gestern ", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 03.03.2020 um 23:31:52 - selector: div.selection_unter_ab:not(:contains("Heute")):not(:contains("Gestern")) - optional: true - filters: - - name: append - args: " +01:00" # CET - - name: replace - args: ["um ", ""] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - text: 288000 # 80h -# THX custom diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/spiritofrevolution.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/spiritofrevolution.yml deleted file mode 100644 index d2f74a6d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/spiritofrevolution.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: spiritofrevolution -name: Spirit of Revolution -description: "Spirit of Revolution is a GERMAN Time based Torrent Tracker for 0DAY" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://sor-next.org/ -legacylinks: - - https://sor-next.tk/ - - https://sor-next.top/ - -caps: - categorymappings: - - {id: 30, cat: Movies/SD, desc: "Filme SD"} - - {id: 134, cat: Movies/HD, desc: "Filme HD"} - - {id: 138, cat: Movies/BluRay, desc: "Filme BluRay"} - - {id: 137, cat: Movies, desc: "Filme Remux"} - - {id: 160, cat: Movies/UHD, desc: "Filme 4K/UHD"} - - {id: 186, cat: Movies/Foreign, desc: "Filme Englisch-SD-HD"} - - {id: 143, cat: Movies/3D, desc: "Filme 3D"} - - {id: 133, cat: Movies/DVD, desc: "Filme DVD"} - - {id: 182, cat: Movies/SD, desc: "Filme Pack/SD"} - - {id: 183, cat: Movies/HD, desc: "Filme Pack/HD"} - - {id: 174, cat: TV/Anime, desc: "Filme Anime"} - - {id: 140, cat: TV/SD, desc: "Serien SD"} - - {id: 135, cat: TV/HD, desc: "Serien HD"} - - {id: 192, cat: TV/UHD, desc: "Serien UHD"} - - {id: 136, cat: TV/SD, desc: "Serien Pack/SD"} - - {id: 180, cat: TV/HD, desc: "Serien Pack/HD"} - - {id: 184, cat: TV/UHD, desc: "Serien Pack/UHD"} - - {id: 179, cat: TV/Anime, desc: "Serien Anime"} - - {id: 128, cat: Audio/MP3, desc: "Audio MP3/AAC"} - - {id: 169, cat: Audio, desc: "Audio Pack"} - - {id: 129, cat: Audio/Lossless, desc: "Audio FLAC"} - - {id: 175, cat: Audio/Video, desc: "Audio Musikvideos"} - - {id: 172, cat: Audio, desc: "Audio Charts"} - - {id: 154, cat: Audio/Audiobook, desc: "Audio Hoerbuch"} - - {id: 29, cat: PC/Games, desc: "Games PC"} - - {id: 13, cat: Console, desc: "Games Konsole"} - - {id: 150, cat: Console/Other, desc: "Games Android"} - - {id: 49, cat: PC/0day, desc: "APPZ Windows"} - - {id: 130, cat: PC/Mac, desc: "APPZ Mac/Linux"} - - {id: 153, cat: PC/Mobile-Android, desc: "APPZ Android"} - - {id: 132, cat: Books, desc: "E-Book Epub"} - - {id: 131, cat: Books, desc: "E-Book PDF"} - - {id: 173, cat: TV/Documentary, desc: "Dokus Packs"} - - {id: 147, cat: TV/Documentary, desc: "Dokus SD / HD"} - - {id: 22, cat: XXX, desc: "XXX Filme"} - - {id: 176, cat: XXX, desc: "XXX Packs"} - - {id: 185, cat: XXX, desc: "XXX PDF/Epub"} - - {id: 156, cat: TV/Sport, desc: "Sport SD / HD"} - - {id: 177, cat: Other, desc: "Sonstiges Alles andere"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: haustuer.php - method: form - form: form[action="haustuer.php"] - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - input[id="username"]: "{{ .Config.username }}" - input[id="password"]: "{{ .Config.password }}" - input[name="loginkeeping"]: loginkeeping - input[name="loginnow"]: yes - selectors: true - selectorinputs: - uufl: - selector: input[name="uufl"] - attribute: value - uupf: - selector: input[name="uupf"] - attribute: value - error: - - selector: "div[style=\"color: #EC0000;\"]" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}scat[]={{.}}&{{end}}{{ else }}{{ end }}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 name, 1 descr, 2 both, 3 imdb - blah: "{{ if .Query.IMDBID }}3{{ else }}0{{ end }}" - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: div.content_wrap > div.selection_wrap:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="selection.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download_ssl.php?torrent="] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - poster: - selector: div[id^="details-"] div div img - attribute: src - description: - selector: div[class^="flag_pic_"] - attribute: onmouseover - filters: - - name: regexp - args: "'(.+?)'" - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - date_day: - # Heute 03:27:27 - # Gestern 21:00:59 - selector: div.selection_unter_ab:contains("Heute"), div.selection_unter_ab:contains("Gestern") - optional: true - filters: - - name: replace - args: ["Heute ", "Today"] - - name: replace - args: ["Gestern ", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 03.03.2020 um 23:31:52 - selector: div.selection_unter_ab:not(:contains("Heute")):not(:contains("Gestern")) - optional: true - filters: - - name: append - args: " +01:00" # CET - - name: replace - args: ["um ", ""] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - text: 172800 # 2 day (48h) -# THX custom diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sportscult.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sportscult.yml deleted file mode 100644 index 47ffff48..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sportscult.yml +++ /dev/null @@ -1,232 +0,0 @@ ---- -id: sportscult -name: SportsCult -description: "SportsCult is a Private Torrent Tracker for SPORTS" -language: en-US -type: private -encoding: UTF-8 -links: - - https://sportscult.org/ - -caps: - categorymappings: - - {id: 47, cat: TV/Sport, desc: "EPL"} - - {id: 41, cat: TV/Sport, desc: "American Football"} - - {id: 54, cat: TV/Sport, desc: "AutoMotoRacing"} - - {id: 17, cat: TV/Sport, desc: "Athletics"} - - {id: 51, cat: TV/Sport, desc: "Baseball"} - - {id: 34, cat: TV/Sport, desc: "Bodybuilding/Fitness"} - - {id: 50, cat: TV/Sport, desc: "Golf"} - - {id: 29, cat: TV/Sport, desc: "Boxing"} - - {id: 19, cat: TV/Sport, desc: "BrainGames"} - - {id: 36, cat: TV/Sport, desc: "BreakDance"} - - {id: 23, cat: TV/Sport, desc: "Cycling"} - - {id: 31, cat: TV/Sport, desc: "Documentary"} - - {id: 1, cat: TV/Sport, desc: "European Basketball"} - - {id: 37, cat: TV/Sport, desc: "Extreme Sports"} - - {id: 9, cat: TV/Sport, desc: "Fight Sports"} - - {id: 32, cat: TV/Sport, desc: "Formula1"} - - {id: 45, cat: TV/Sport, desc: "GAA (Gaelic)"} - - {id: 22, cat: TV/Sport, desc: "Gymnastics"} - - {id: 59, cat: TV/Sport, desc: "UFL"} - - {id: 39, cat: TV/Sport, desc: "Handball"} - - {id: 2, cat: TV/Sport, desc: "International Basket"} - - {id: 25, cat: TV/Sport, desc: "IceHockey"} - - {id: 4, cat: TV/Sport, desc: "International Soccer"} - - {id: 42, cat: TV/Sport, desc: "KHL"} - - {id: 55, cat: TV/Sport, desc: "MotoGP"} - - {id: 35, cat: TV/Sport, desc: "KickBoxing/Muay Thai"} - - {id: 43, cat: TV/Sport, desc: "La Liga"} - - {id: 15, cat: TV/Sport, desc: "MotorSport"} - - {id: 24, cat: TV/Sport, desc: "MLB/Baseball"} - - {id: 28, cat: TV/Sport, desc: "MMA"} - - {id: 11, cat: TV/Sport, desc: "NBA"} - - {id: 3, cat: TV/Sport, desc: "NCAA Basket/Football"} - - {id: 5, cat: TV/Sport, desc: "NFL"} - - {id: 27, cat: TV/Sport, desc: "NHL"} - - {id: 26, cat: TV/Sport, desc: "Olympic games"} - - {id: 7, cat: TV/Sport, desc: "Rugby"} - - {id: 44, cat: TV/Sport, desc: "Serie A"} - - {id: 38, cat: TV/Sport, desc: "Snooker/Pool"} - - {id: 30, cat: TV/Sport, desc: "Streetball"} - - {id: 18, cat: TV/Sport, desc: "Swimming/Aquatics"} - - {id: 56, cat: TV/Sport, desc: "Ligue1"} - - {id: 46, cat: TV/Sport, desc: "AFL(AustralianFB)"} - - {id: 12, cat: TV/Sport, desc: "Tennis"} - - {id: 20, cat: TV/Sport, desc: "Volleyball"} - - {id: 21, cat: TV/Sport, desc: "Weightlifting"} - - {id: 16, cat: TV/Sport, desc: "WinterSport"} - - {id: 33, cat: TV/Sport, desc: "Wrestling/Grapling"} - - {id: 48, cat: TV/Sport, desc: "Uncategorised"} - - {id: 60, cat: TV/Sport, desc: "Champions League"} - - {id: 61, cat: TV/Sport, desc: "Europa League"} - - {id: 63, cat: TV/Sport, desc: "WNBA"} - - {id: 64, cat: TV/Sport, desc: "NASCAR"} - - {id: 65, cat: TV/Sport, desc: "FIBA 3x3 Basketball"} - - {id: 67, cat: TV/Sport, desc: "Bundesliga"} - - {id: 68, cat: TV/Sport, desc: "MLS"} - - {id: 69, cat: TV/Sport, desc: "EuroLeague Basketbal"} - - {id: 70, cat: TV/Sport, desc: "ELC"} - - {id: 71, cat: TV/Sport, desc: "RhythmicGymnastics"} - - {id: 72, cat: TV/Sport, desc: "BeachVolleyball"} - - {id: 73, cat: TV/Sport, desc: "BeachSoccer"} - - {id: 74, cat: TV/Sport, desc: "Chess"} - - {id: 75, cat: TV/Sport, desc: "Bowling"} - - {id: 76, cat: TV/Sport, desc: "BelgianProLeague"} - - {id: 77, cat: TV/Sport, desc: "Bellator"} - - {id: 78, cat: TV/Sport, desc: "Billard"} - - {id: 79, cat: TV/Sport, desc: "Climbing"} - - {id: 80, cat: TV/Sport, desc: "CrossFit"} - - {id: 81, cat: TV/Sport, desc: "DutchEredivisie"} - - {id: 82, cat: TV/Sport, desc: "IndyCar"} - - {id: 83, cat: TV/Sport, desc: "GLeagueNBA"} - - {id: 84, cat: TV/Sport, desc: "NCAABasketball"} - - {id: 85, cat: TV/Sport, desc: "NCAAFootball"} - - {id: 86, cat: TV/Sport, desc: "Sailing"} - - {id: 87, cat: TV/Sport, desc: "Surfing"} - - {id: 88, cat: TV/Sport, desc: "TableTennis"} - - {id: 89, cat: TV/Sport, desc: "TourDeFrance"} - - {id: 90, cat: TV/Sport, desc: "LaVuelta"} - - {id: 91, cat: TV/Sport, desc: "Girod'Italia"} - - {id: 92, cat: TV/Sport, desc: "UefaConferenceLeague"} - - {id: 93, cat: TV/Sport, desc: "UEFAEuro"} - - {id: 94, cat: TV/Sport, desc: "OlympicGamesParis24"} - - {id: 95, cat: TV/Sport, desc: "WRCRally"} - - {id: 96, cat: TV/Sport, desc: "CPL"} - - {id: 97, cat: TV/Sport, desc: "CFL"} - - {id: 98, cat: TV/Sport, desc: "PrimeiraPortugal"} - - {id: 53, cat: TV/Sport, desc: "Cricket"} - - {id: 99, cat: TV/Sport, desc: "Darts"} - - {id: 100, cat: TV/Sport, desc: "ESport"} - - {id: 6, cat: TV/Sport, desc: "European Soccer"} - - {id: 101, cat: TV/Sport, desc: "EuroCup"} - - {id: 102, cat: TV/Sport, desc: "Ultimate Diskk"} - - {id: 104, cat: TV/Sport, desc: "WinterOlympicGames"} - - {id: 52, cat: TV/Sport, desc: "Field Hockey"} - - {id: 103, cat: TV/Sport, desc: "Basketball Champions"} - - {id: 58, cat: TV/Sport, desc: "UFC"} - - {id: 57, cat: TV/Sport, desc: "NRL"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: index.php?page=login - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: td.lista span:contains("Username Incorrect") - - selector: td.lista span:contains("Password Incorrect") - - selector: td.lista span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://sportscult.org/index.php?search=&page=torrents&category=0&active=0&order=3&by=2 - - path: index.php - inputs: - page: torrents - search: "{{ .Keywords }}" - category: "{{ if .Categories }}{{ join .Categories \";\" }}{{ else }}0{{ end }}" - # 0 all, 1 active, 2 dead - active: 0 - gold: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: table.lista:last-of-type:not(:has(td.block:contains("Our Team Recommend"))) > tbody > tr:has(a[href^="index.php?page=torrents&category="]) - - fields: - category: - selector: a[href^="index.php?page=torrents&category="] - attribute: href - filters: - - name: querystring - args: category - # change broken/missing cat 0 to Uncategorised - - name: re_replace - args: ["\\b0\\b", "48"] - title: - selector: a[href^="index.php?page=torrent-details&id="] - filters: - # strip the upright bars in the title - - name: re_replace - args: ["( \\| )+", " "] - details: - selector: a[href^="index.php?page=torrent-details&id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - filters: - # strip the upright bars in the title's filename - - name: re_replace - args: ["(\\+%7C\\+)+", "+"] - size: - selector: td:nth-child(4) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - date: - selector: td:nth-child(5) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd/MM/yyyy" - downloadvolumefactor: - case: - img[src="gold/gold.gif"]: 0 - img[src="gold/silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.9 - minimumseedtime: - # 36 hours (as seconds = 36 x 60 x 60) - text: 129600 -# xbtit 2.3.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/springsunday.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/springsunday.yml deleted file mode 100644 index cb92c9c4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/springsunday.yml +++ /dev/null @@ -1,167 +0,0 @@ ---- -id: springsunday -name: SpringSunday -description: "SpringSunday (SSD) is a CHINESE Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://springsunday.net/ - -caps: - categorymappings: - - {id: 501, cat: Movies, desc: "Movies(电影)"} - - {id: 502, cat: TV, desc: "TV Series(剧集)"} - - {id: 503, cat: TV/Documentary, desc: "Docs(纪录)"} - - {id: 504, cat: TV/Anime, desc: "Animations(动画)"} - - {id: 505, cat: TV, desc: "TV Shows(综艺)"} - - {id: 506, cat: TV/Sport, desc: "Sports(体育)"} - - {id: 507, cat: Audio/Video, desc: "MV(音乐视频)"} - - {id: 508, cat: Audio, desc: "Music(音乐)"} - - {id: 509, cat: Other, desc: "Others(其他)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # switched from the form method to the cookie method due to #8911 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="logout.php?auth="] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 onlydead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xFree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 all, 1 popular, 2 classic, 3 recomended, 4 2+3 - pick: 0 - # 0 title, 3 uploader, 4 imdb URL, 5 douban URL - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}5{{ else }}{{ end }}" - # 0 AND, 1 OR, 2 Exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - # supports imdbid and douban searching but does not display imdb and douban link in results. - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-last-child(6) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-last-child(6):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-last-child(5) - seeders: - selector: td.rowfollow:nth-last-child(4) - leechers: - selector: td.rowfollow:nth-last-child(3) - grabs: - selector: td.rowfollow:nth-last-child(2) - downloadvolumefactor: - case: - span.torrent-pro-free: 0 - span.torrent-pro-twoupfree: 0 - span.torrent-pro-halfdown: 0.5 - span.torrent-pro-twouphalfdown: 0.5 - span.torrent-pro-thirtypercent: 0.3 - "*": 1 - uploadvolumefactor: - case: - span.torrent-pro-twouphalfdown: 2 - span.torrent-pro-twoupfree: 2 - span.torrent-pro-twoup: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 (customised) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sukebeinyaasi.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sukebeinyaasi.yml deleted file mode 100644 index c2739916..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/sukebeinyaasi.yml +++ /dev/null @@ -1,123 +0,0 @@ ---- -id: sukebeinyaasi -name: sukebei.nyaa.si -description: "sukebei.nyaa is a Public torrent site focused on adult Eastern ASIAN media including anime, manga, games and JAV" -language: en-US -type: public -encoding: UTF-8 -links: - - https://sukebei.nyaa.si/ - - https://sukebei.nyaa.mom/ -legacylinks: - - https://sukebei.nyaa.lol/ - -settings: - - name: filter-id - type: select - label: Filter - default: 0 - options: - 0: No filter - 1: No remakes - 2: Trusted only - - name: cat-id - type: select - label: Category - default: 0_0 - options: - 0_0: "All categories" - 1_0: "Art" - 1_1: "Art - Anime" - 1_2: "Art - Doujinshi" - 1_3: "Art - Games" - 1_4: "Art - Manga" - 1_5: "Art - Pictures" - 2_0: "Real Life" - 2_1: "Real Life - Photobooks and Pictures" - 2_2: "Real Life - Videos" - - name: sort - type: select - label: Sort requested from site - default: id - options: - id: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -caps: - categorymappings: - # Art - - {id: 1_0, cat: XXX, desc: "Adult Art"} - - {id: 1_1, cat: XXX, desc: "Adult Anime"} - - {id: 1_2, cat: XXX, desc: "Doujinshi"} - - {id: 1_3, cat: XXX, desc: "Adult Games"} - - {id: 1_4, cat: XXX, desc: "Adult Manga"} - - {id: 1_5, cat: XXX, desc: "Adult Pictures"} - # Real Life - - {id: 2_0, cat: XXX, desc: "Adult Real Life"} - - {id: 2_1, cat: XXX, desc: "Adult Photobooks and Pictures"} - - {id: 2_2, cat: XXX, desc: "Adult Videos"} - - modes: - search: [q] - -search: - paths: - - path: / - inputs: - q: "{{ .Keywords }}" - f: "{{ .Config.filter-id }}" - c: "{{ .Config.cat-id }}" - s: "{{ .Config.sort }}" - o: "{{ .Config.type }}" - - rows: - selector: tr.default,tr.danger,tr.success - - fields: - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: split - args: ["=", -1] - title: - selector: td:nth-child(2) a:last-of-type - details: - selector: td:nth-child(2) a:last-of-type - attribute: href - download: - selector: td:nth-child(3) a[href$=".torrent"] - optional: true - attribute: href - magnet: - selector: td:nth-child(3) a[href^="magnet:?"] - optional: true - attribute: href - size: - selector: td:nth-child(4) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " -00:00" # GMT - - name: dateparse - args: "yyyy-MM-dd HH:mm zzz" - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/superbits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/superbits.yml deleted file mode 100644 index 30a1c352..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/superbits.yml +++ /dev/null @@ -1,277 +0,0 @@ ---- -id: superbits -name: Superbits -description: "SuperBits (SBS) is a SWEDISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: sv-SE -type: private -encoding: UTF-8 -links: - - https://superbits.org/ - - https://superbits.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies/DVD, desc: DVD-R Swesub} - - {id: 2, cat: TV, desc: DVD-R TV} - - {id: 3, cat: Books/EBook, desc: eBok} - - {id: 4, cat: Movies/HD, desc: Film 1080} - - {id: 5, cat: Movies/3D, desc: Film 3D} - - {id: 6, cat: Movies/HD, desc: Film 720} - - {id: 7, cat: Movies/BluRay, desc: Film Bluray} - - {id: 24, cat: Movies/UHD, desc: Film 4K} - - {id: 8, cat: TV, desc: Svensk TV} - - {id: 9, cat: Audio/Audiobook, desc: Ljudböcker} - - {id: 10, cat: Audio/Video, desc: Musikvideos} - - {id: 11, cat: Books/Mags, desc: E-tidningar} - - {id: 12, cat: Audio, desc: MP3} - - {id: 13, cat: Audio, desc: Pod} - - {id: 14, cat: Other, desc: Övrigt} - - {id: 15, cat: PC/Games, desc: PC-Spel} - - {id: 16, cat: PC/0day, desc: Program} - - {id: 17, cat: Console/PS4, desc: Playstation} - - {id: 18, cat: TV, desc: TV} - - {id: 19, cat: Console/Other, desc: Nintendo} - - {id: 20, cat: Console/XBox, desc: Xbox} - - {id: 21, cat: Movies/SD, desc: Xvid} - - {id: 25, cat: Movies/Foreign, desc: Subpacks} - - {id: 22, cat: XXX, desc: XXX} - - {id: 23, cat: Audio/Lossless, desc: FLAC} - - {id: 26, cat: TV, desc: TV DK} - - {id: 27, cat: TV, desc: TV NO} - - {id: 28, cat: TV, desc: TV FI} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: p2p_releases_only - type: checkbox - label: Search P2P releases only - default: false - - name: sort - type: select - label: Sort requested from site - default: d - options: - d: created - up: seeders - s: size - n: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If you have not logged in, via web browser, for 3 months, the account will be suspended. You can park your account in Settings which means that it will not be deleted for inactivity for at least 1 year." - -login: - # switched from the form method to the cookie method due to #12468 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - -search: - paths: - - path: api/v1/torrents - response: - type: json - noResultsMessage: "" - - inputs: - $raw: "{{ range .Categories }}categories[]={{.}}&{{end}}" - dkaudio: false - dksub: false - enaudio: false - ensub: false - extendedSearch: false - fiaudio: false - fisub: false - freeleech: "{{ if .Config.freeleech }}true{{ else }}false{{ end }}" - index: 0 - limit: 100 - noaudio: false - nosub: false - order: "{{ .Config.type }}" - p2p: "{{ if .Config.p2p_releases_only }}true{{ else }}{{ end }}" - page: search - searchText: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }} {{ else }}{{ end }}{{ .Keywords }}" - section: all - sort: "{{ .Config.sort }}" - stereoscopic: false - sweaudio: false - swesub: false - watchview: false - - rows: - selector: $ - - fields: - category: - selector: category - title: - selector: name - _id: - selector: id - details: - text: "/torrent/{{ .Result._id }}/" - download: - text: "/api/v1/torrents/download/{{ .Result._id }}" - imdbid: - selector: imdbid2 - imdbid_full: - optional: true - selector: imdbid2 - poster_normal: - optional: true - selector: customcover - poster_imdb: - optional: true - text: "{{ if .Result.imdbid }}/img/imdb/{{ .Result.imdbid_full }}.jpg{{ else }}{{ end }}" - poster: - text: "{{ if .Result.poster_imdb }}{{ .Result.poster_imdb }}{{ else }}{{ .Result.poster_normal }}{{ end }}" - date: - # 2021-10-26 13:50:07 - selector: added - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: size - files: - selector: numfiles - grabs: - selector: timesCompleted - optional: true - default: 0 - seeders: - selector: seeders - leechers: - selector: leechers - downloadvolumefactor: - # api returns 0=false, 1=true - selector: frileech - case: - 0: 1 # not free - 1: 0 # freeleech - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - title_desc: - optional: true - selector: title - year: - optional: true - selector: year - genre: - selector: genres - _tagline_desc: - optional: true - selector: tagline - _cast_desc: - optional: true - selector: cast - _rating_desc: - optional: true - selector: rating - filters: - - name: re_replace - args: ["^0$", ""] - _plot_desc: - optional: true - selector: plot - _section_desc: - optional: true - selector: section - _predate_desc: - selector: preDate - filters: - - name: re_replace - args: ["1970-01-01 01:00:00", ""] - _p2ptag: - selector: p2p - case: - 0: "" - 1: P2P - _packtag: - selector: pack - case: - 0: "" - 1: Pack - _reqidtag: - selector: reqid - case: - 0: "" - "*": Request - _sweaudiotag: - selector: sweaudio - case: - 0: "" - "*": "Swedish audio" - _swesubtag: - selector: swesub - case: - 0: "" - "*": "Swedish subtitles" - _dkaudiotag: - selector: dkaudio - case: - 0: "" - "*": "Danish audio" - _dksubtag: - selector: dksub - case: - 0: "" - "*": "Danish subtitles" - _enaudiotag: - selector: enaudio - case: - 0: "" - "*": "English audio" - _ensubtag: - selector: ensub - case: - 0: "" - "*": "English subtitles" - _noaudiotag: - selector: noaudio - case: - 0: "" - "*": "Norwegian audio" - _nosubtag: - selector: nosub - case: - 0: "" - "*": "Norwegian subtitles" - _tag_desc: - optional: true - text: "{{ if .Result._p2ptag }}{{ .Result._p2ptag }}, {{ else }}{{ end }}{{ if .Result._packtag }}{{ .Result._packtag }}, {{ else }}{{ end }}{{ if .Result._reqidtag }}{{ .Result._reqidtag }}, {{ else }}{{ end }}{{ if .Result._sweaudiotag }}{{ .Result._sweaudiotag }}, {{ else }}{{ end }}{{ if .Result._swesubtag }}{{ .Result._swesubtag }}, {{ else }}{{ end }}{{ if .Result._dkaudiotag }}{{ .Result._dkaudiotag }}, {{ else }}{{ end }}{{ if .Result._dksubtag }}{{ .Result._dksubtag }}, {{ else }}{{ end }}{{ if .Result._enaudiotag }}{{ .Result._enaudiotag }}, {{ else }}{{ end }}{{ if .Result._ensubtag }}{{ .Result._ensubtag }}, {{ else }}{{ end }}{{ if .Result._noaudiotag }}{{ .Result._noaudiotag }}, {{ else }}{{ end }}{{ if .Result._nosubtag }}{{ .Result._nosubtag }}{{ else }}{{ end }}" - filters: - - name: regexp - args: "(.+?), $" - description: - text: "{{ if .Result.title_desc }}Title: {{ .Result.title_desc }}
{{ else }}{{ end }}{{ if .Result.year }}Year: {{ .Result.year }}
{{ else }}{{ end }}{{ if .Result.genre }}Genres: {{ .Result.genre }}
{{ else }}{{ end }}{{ if .Result._tagline_desc }}Tagline: {{ .Result._tagline_desc }}
{{ else }}{{ end }}{{ if .Result._cast_desc }}Cast: {{ .Result._cast_desc }}
{{ else }}{{ end }}{{ if .Result._rating_desc }}Rating: {{ .Result._rating_desc }}
{{ else }}{{ end }}{{ if .Result._plot_desc }}Plot: {{ .Result._plot_desc }}
{{ else }}{{ end }}{{ if .Result._tag_desc }}Tags: {{ .Result._tag_desc }}
{{ else }}{{ end }}{{ if .Result._predate_desc }}PRE: {{ .Result._predate_desc }}
{{ else }}{{ end }}{{ if .Result._section_desc }}Section: {{ .Result._section_desc }}{{ else }}{{ end }}" -# json rartracker api v1 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/swarmazon-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/swarmazon-api.yml deleted file mode 100644 index c50da23e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/swarmazon-api.yml +++ /dev/null @@ -1,116 +0,0 @@ ---- -id: swarmazon-api -name: Swarmazon (API) -description: "Swarmazon is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://swarmazon.club/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "Television"} - - {id: 3, cat: Other, desc: "Video Courses"} - - {id: 4, cat: Audio, desc: "Music"} - - {id: 5, cat: PC/Games, desc: "Games"} - - {id: 6, cat: PC, desc: "Software"} - - {id: 7, cat: TV/Anime, desc: "Anime"} - - {id: 8, cat: Books/EBook, desc: "E-Books"} - - {id: 9, cat: Audio/Audiobook, desc: "Audio Books"} - - {id: 10, cat: Books/Comics, desc: "Comics"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing the Token Setting page." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - path: api/search.php - method: get - inputs: - api_key: "{{ .Config.apikey }}" - limit: 1 - error: - - selector: ":root:contains(\"Invalid API key\")" - -search: - paths: - - path: api/search.php - response: - type: json - - inputs: - api_key: "{{ .Config.apikey }}" - filter: "{{ .Keywords }}" - media_ref: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}" - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - limit: 100 - - rows: - selector: data - - fields: - category: - selector: categoryid - title: - selector: name - details: - selector: torrent - download: - selector: download - poster: - selector: media_poster - filters: - - name: replace - args: ["N\/A", ""] - genre: - selector: media_genre - description: - text: "{{ .Result.genre }}" - infohash: - selector: hash - imdbid: - selector: ref_id:contains(tt) - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: completed - date: - # 2022-11-16 22:49:25 - selector: upload_timestamp - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: size - downloadvolumefactor: - selector: freeleech - case: - no: 1 # not free - "*": 0 # freeleech - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.1 -# json v1.22.0620 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tangmen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tangmen.yml deleted file mode 100644 index 0552315b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tangmen.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: tangmen -name: Tangmen (唐门) -description: "Tangmen (唐门) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://tmpt.top/ - -caps: - categorymappings: - - {id: 409, cat: TV/Other, desc: "Others/其他"} - - {id: 408, cat: Audio, desc: "Music/音轨"} - - {id: 406, cat: Audio/Video, desc: "Music Videos/MV"} - - {id: 407, cat: TV/Sport, desc: "Sports/体育"} - - {id: 410, cat: TV, desc: "Playlet(短剧)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries/纪录片"} - - {id: 405, cat: TV/Anime, desc: "Animations/动漫"} - - {id: 403, cat: TV, desc: "TV Shows/综艺"} - - {id: 402, cat: TV, desc: "TV Series/电视剧"} - - {id: 401, cat: Movies, desc: "Movies/电影"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tangpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tangpt.yml deleted file mode 100644 index 3f555985..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tangpt.yml +++ /dev/null @@ -1,189 +0,0 @@ ---- -id: tangpt -name: TangPT (躺平) -description: "TangPT (躺平) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.tangpt.top/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "电影"} - - {id: 402, cat: TV, desc: "电视剧"} - - {id: 403, cat: TV, desc: "综艺"} - - {id: 404, cat: TV/Documentary, desc: "纪录片"} - - {id: 405, cat: TV/Anime, desc: "动漫"} - - {id: 406, cat: Audio/Video, desc: "MV"} - - {id: 407, cat: TV/Sport, desc: "体育"} - - {id: 409, cat: Audio/Lossless, desc: "音乐"} - - {id: 414, cat: Other, desc: "其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted
  2. Elite User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row
  4. Un-parked accounts would be deleted if users have not logged in for more than 150 days in a row
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tapochek.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tapochek.yml deleted file mode 100644 index 217b03dd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tapochek.yml +++ /dev/null @@ -1,430 +0,0 @@ ---- -id: tapochek -name: Tapochek -description: "Tapochek is a RUSSIAN Private Torrent Tracker for 0DAY / GENERAL" -language: ru-RU -type: private -encoding: windows-1251 -links: - - https://tapochek.net/ -legacylinks: - - http://tapochek.net/ - -caps: - categorymappings: - # PC Games # Игры для PC - - {id: 2, cat: PC/Games, desc: "Игры для PC (в разработке, разное, обсуждения)"} - - {id: 14, cat: PC/Games, desc: "Игры в разработке и демо-версии"} - - {id: 9, cat: PC/Games, desc: "Разное (дополнения, патчи, русификаторы)"} - - {id: 18, cat: PC/Games, desc: "Горячие новинки"} - - {id: 24, cat: PC/Games, desc: "Приключения и квесты"} - - {id: 25, cat: PC/Games, desc: "Стратегии"} - - {id: 19, cat: PC/Games, desc: "Аркады"} - - {id: 29, cat: PC/Games, desc: "Симуляторы"} - - {id: 35, cat: PC/Games, desc: "Экшены"} - - {id: 40, cat: PC/Games, desc: "Ролевые игры"} - - {id: 41, cat: PC/Games, desc: "Эротические игры"} - - {id: 43, cat: PC/Games, desc: "Эротические"} - - {id: 42, cat: PC/Games, desc: "Хентайные"} - - {id: 968, cat: PC/Games, desc: "Игры для VR"} - - {id: 46, cat: PC/Games, desc: "Для самых маленьких"} - - {id: 978, cat: PC/Games, desc: "Игры для macOS и Linux"} - # Games for Consoles # Игры для Консолей - - {id: 69, cat: Console/XBox, desc: "Xbox"} - - {id: 86, cat: Console/XBox 360, desc: "XBox360 | Игры"} - - {id: 646, cat: Console/XBox 360, desc: "XBox360 | JTAG"} - - {id: 87, cat: Console/XBox 360, desc: "XBox360 | 360E"} - - {id: 89, cat: Console/XBox 360, desc: "XBox360 | Demo"} - - {id: 612, cat: Console/XBox 360, desc: "XBox360 | Live"} - - {id: 614, cat: Console/XBox 360, desc: "XBox360 | Soft"} - - {id: 88, cat: Console/XBox, desc: "XBox | Игры"} - - {id: 70, cat: Console/PSP, desc: "PlayStation"} - - {id: 973, cat: Console/PS4, desc: "PS5 | Игры"} - - {id: 910, cat: Console/PS4, desc: "PS4 | Игры"} - - {id: 911, cat: Console/PS4, desc: "PS4 | Сцена"} - - {id: 689, cat: Console/PS3, desc: "PS3 | Игры"} - - {id: 818, cat: Console/PS3, desc: "PS3 | Сцена"} - - {id: 696, cat: Console/PSP, desc: "PSN | Игры"} - - {id: 853, cat: Console/PS3, desc: "PS3 | Emulation"} - - {id: 904, cat: Console/PS Vita, desc: "PS Vita | Игры"} - - {id: 102, cat: Console/PSP, desc: "PSP | Игры"} - - {id: 103, cat: Console/PS3, desc: "PS2 | Игры"} - - {id: 104, cat: Console/PSP, desc: "PSX | Игры"} - - {id: 435, cat: Console/PSP, desc: "PSP | Сцена"} - - {id: 105, cat: Console/PSP, desc: "PSP | PSX-PSP"} - - {id: 690, cat: Console/PS3, desc: "PS3 | Прочее"} - - {id: 178, cat: Console/PSP, desc: "PSP | Прочее"} - - {id: 78, cat: Console/NDS, desc: "Nintendo"} - - {id: 148, cat: Console/NDS, desc: "Архив (Nintendo)"} - - {id: 116, cat: Console/NDS, desc: "NDS | Игры"} - - {id: 885, cat: Console/3DS, desc: "3DS | Игры"} - - {id: 912, cat: Console, desc: "Switch | Игры"} - - {id: 115, cat: Console/Wii, desc: "Wii | Игры"} - - {id: 899, cat: Console/WiiU, desc: "Wii U | Игры"} - - {id: 900, cat: Console/WiiU, desc: "Wii U | Сцена"} - - {id: 381, cat: Console/Wiiware, desc: "WiiWare | Игры"} - - {id: 382, cat: Console, desc: "GameCube | Игры"} - - {id: 349, cat: Console, desc: "Sega"} - - {id: 350, cat: Console, desc: "Dreamcast | Игры"} - - {id: 351, cat: Console, desc: "Saturn | Игры"} - - {id: 352, cat: Console, desc: "Игры для старых консолей"} - - {id: 390, cat: Console, desc: "Коллекции (игры для разных платформ)"} - # Cinema # Кинематограф - - {id: 964, cat: Movies/UHD, desc: "Новинки | UHD 4K"} - - {id: 431, cat: Movies/WEB-DL, desc: "Новинки | HD"} - - {id: 430, cat: Movies, desc: "Новинки | Rips"} - - {id: 703, cat: Movies, desc: "Новинки | CAMRip, TS, WEBRip(LQ)"} - - {id: 963, cat: Movies/UHD, desc: "Зарубежное кино | UHD 4K"} - - {id: 122, cat: Movies/HD, desc: "Зарубежное кино | HD"} - - {id: 124, cat: Movies/Foreign, desc: "Зарубежное кино | Rips"} - - {id: 123, cat: Movies, desc: "Сборники фильмов"} - - {id: 974, cat: Movies/UHD, desc: "Отечественное кино | UHD 4K"} - - {id: 128, cat: Movies/HD, desc: "Отечественное кино | HD Rips"} - - {id: 133, cat: Movies/DVD, desc: "Отечественное кино | DVD"} - - {id: 131, cat: Movies, desc: "Российское и советское кино | Rips"} - - {id: 869, cat: Movies/3D, desc: "3D"} - - {id: 325, cat: TV/UHD, desc: "Мультфильмы | UHD 4K"} - - {id: 324, cat: TV/HD, desc: "Мультфильмы | HD Rips"} - - {id: 328, cat: TV, desc: "Мультфильмы | Rips"} - - {id: 330, cat: TV, desc: "Сборники мультфильмов"} - - {id: 321, cat: TV, desc: "Мультсериалы"} - - {id: 162, cat: TV/Documentary, desc: "Документальные фильмы и телешоу Сериалы"} - - {id: 982, cat: TV/UHD, desc: "Новинки зарубежных сериалов UHD|4K"} - - {id: 886, cat: TV, desc: "Новинки зарубежных сериалов"} - - {id: 986, cat: TV/UHD, desc: "Зарубежные сериалы UHD|4K до 2025г"} - - {id: 135, cat: TV, desc: "Зарубежные сериалы до 2025г."} - - {id: 160, cat: TV, desc: "Новинки русских сериалов"} - - {id: 979, cat: TV, desc: "Русские сериалы до 2025г"} - # Anime # Аниме - - {id: 693, cat: TV/Anime, desc: "Аниме (Основной)"} - - {id: 660, cat: TV/Anime, desc: "Онгоинги и новинки аниме"} - - {id: 95, cat: TV/Anime, desc: "Аниме (HD)"} - - {id: 684, cat: TV/Anime, desc: "Аниме (Rips | DVD)"} - - {id: 106, cat: TV/Anime, desc: "Манга и прочий арт"} - - {id: 680, cat: TV/Anime, desc: "Аниме (Хентай)"} - - {id: 682, cat: TV/Anime, desc: "UnCensored"} - - {id: 681, cat: TV/Anime, desc: "Censored"} - - {id: 697, cat: TV/Anime, desc: "Манга, обои, артбуки и др."} - - {id: 988, cat: TV/Anime, desc: "Китайская и корейская анимация"} - # Music and Music Video # Музыка и Музыкальное видео - - {id: 450, cat: Audio, desc: "Общий раздел музыки"} - - {id: 470, cat: Audio, desc: "Классическая музыка"} - - {id: 495, cat: Audio, desc: "New Age, Relax, Meditative & Flamenco"} - - {id: 456, cat: Audio, desc: "Jazz, Blues"} - - {id: 462, cat: Audio, desc: "Reggae, Ska, Dub"} - - {id: 491, cat: Audio, desc: "Фольклор, Народная и Этническая музыка"} - - {id: 468, cat: Audio, desc: "Зарубежный Rock"} - - {id: 469, cat: Audio/Lossless, desc: "Rосk, Mеtаl, Аltеrnаtivе, Рunk, Indереndеnt (lоsslеss)"} - - {id: 472, cat: Audio/MP3, desc: "Rосk, Mеtаl, Аltеrnаtivе, Рunk, Indереndеnt (mp3)"} - - {id: 476, cat: Audio, desc: "Отечественный Rock"} - - {id: 477, cat: Audio/Lossless, desc: "Rосk, Metal, Punk, Alternative (losslеss)"} - - {id: 479, cat: Audio/MP3, desc: "Rосk, Metal, Punk, Alternative (mр3)"} - - {id: 482, cat: Audio, desc: "Поп-музыка, Eurodance, Disco"} - - {id: 483, cat: Audio, desc: "Зарубежная Поп-музыка"} - - {id: 484, cat: Audio, desc: "Отечественная Поп-музыка"} - - {id: 485, cat: Audio, desc: "Eurodance, Technopop, Disco"} - - {id: 489, cat: Audio/MP3, desc: "Поп-музыка, Eurodance, Disco (сборники) (mp3)"} - - {id: 503, cat: Audio, desc: "Rap, Hip-Hop, R'n'B"} - - {id: 504, cat: Audio, desc: "Зарубежный Rap, Hip-Hop, R'n'B"} - - {id: 505, cat: Audio, desc: "Отечественный Rap, Hip-Hop, R'n'B"} - - {id: 509, cat: Audio, desc: "Электронная музыка"} - - {id: 510, cat: Audio, desc: "Trance, Goa Trance, Psy-Trance, PsyChill, Ambient Dub"} - - {id: 511, cat: Audio, desc: "House, Techno, Hardcore, Hardstyle, Jumpstyle"} - - {id: 512, cat: Audio, desc: "Drum & Bass, Jungle, Breakbeat, Dubstep, IDM"} - - {id: 513, cat: Audio, desc: "Chillout, Lounge, Downtempo, Trip-Hop"} - - {id: 514, cat: Audio, desc: "Traditional Electronic, Ambient, Experimental"} - - {id: 515, cat: Audio, desc: "Industrial, Noise, EBM, Dark Electro, Aggrotech, Synthpop, N.."} - - {id: 534, cat: Audio, desc: "Музыка разное"} - - {id: 396, cat: Audio, desc: "Саундтреки"} - - {id: 542, cat: Audio, desc: "Неофициальные и внежанровые сборники"} - - {id: 547, cat: Audio, desc: "Hi-Res stereo"} - - {id: 546, cat: Audio, desc: "Собственные оцифровки"} - - {id: 535, cat: Audio, desc: "Музыка других жанров"} - - {id: 539, cat: Audio, desc: "Музыкальное видео"} - # Software # Программное обеспечение - - {id: 639, cat: TV, desc: "Обучающие видеоматериалы"} - - {id: 202, cat: TV, desc: "macOS и ПО под них"} - - {id: 274, cat: TV, desc: "Веб-разработка и программирование"} - - {id: 303, cat: TV, desc: "Материалы для мультимедиа и дизайна"} - - {id: 969, cat: PC, desc: "Windows 11"} - - {id: 905, cat: PC, desc: "Windows 10"} - - {id: 230, cat: PC, desc: "Windows 7"} - - {id: 227, cat: PC, desc: "Настольные OS, выпущенные до 2001 года (Microsoft Windows OS.."} - - {id: 970, cat: PC, desc: "Сборки (Microsoft Windows 11)"} - - {id: 906, cat: PC, desc: "Сборки (Microsoft Windows 10)"} - - {id: 701, cat: PC, desc: "Сборки (Microsoft Windows 7)"} - - {id: 231, cat: PC, desc: "Серверные (Microsoft Windows OS)"} - - {id: 232, cat: PC, desc: "Разное (Microsoft Windows, WinXP, Vista, 8)"} - - {id: 236, cat: PC, desc: "Работа с жёстким диском"} - - {id: 237, cat: PC, desc: "Резервное копирование"} - - {id: 240, cat: PC, desc: "Архиваторы и файловые менеджеры"} - - {id: 239, cat: PC, desc: "Программы для настройки и оптимизации ОС"} - - {id: 241, cat: PC, desc: "Сервисное обслуживание компьютера"} - - {id: 242, cat: PC, desc: "Работа с носителями информации"} - - {id: 243, cat: PC, desc: "Информация и диагностика"} - - {id: 244, cat: PC, desc: "Программы для интернет и сетей"} - - {id: 245, cat: PC, desc: "Антивирусы и Файерволы"} - - {id: 246, cat: PC, desc: "Программы для защиты информации"} - - {id: 247, cat: PC, desc: "Драйвера"} - - {id: 248, cat: PC, desc: "Серверное ПО для Windows"} - - {id: 249, cat: PC, desc: "Изменение интерфейса ОС Windows"} - - {id: 250, cat: PC, desc: "Скринсейверы"} - - {id: 251, cat: PC, desc: "Разное (Системные программы под Windows)"} - - {id: 409, cat: PC, desc: "Системы автоматизации проектных работ (САПР)"} - - {id: 607, cat: PC, desc: "Программы для архитекторов и дизайнеров интерьеров"} - - {id: 593, cat: PC, desc: "Офисные программы и системы"} - - {id: 595, cat: PC, desc: "Системы для научной работы"} - - {id: 594, cat: PC, desc: "Системы для бизнеса"} - - {id: 257, cat: PC, desc: "Каталогизаторы и просмотрщики графики"} - - {id: 258, cat: PC, desc: "Аудио- и видео-, CD- проигрыватели и каталогизаторы"} - - {id: 259, cat: PC, desc: "Программы для интернет и сетей"} - - {id: 260, cat: PC, desc: "Распознавание текста, звука и синтез речи"} - - {id: 261, cat: PC, desc: "Словари и переводчики"} - - {id: 262, cat: PC, desc: "Программное обеспечение для автолюбителей"} - - {id: 263, cat: PC, desc: "Медицинское программное обеспечение"} - - {id: 264, cat: PC, desc: "Справочные системы и карты"} - - {id: 265, cat: PC, desc: "Разное (Пользовательские программы)"} - - {id: 278, cat: PC, desc: "Программные комплекты"} - - {id: 280, cat: PC, desc: "Графические редакторы"} - - {id: 281, cat: PC, desc: "Программы для верстки, печати и работы со шрифтами"} - - {id: 282, cat: PC, desc: "3D моделирование, рендеринг и плагины для них"} - - {id: 283, cat: PC, desc: "Анимация"} - - {id: 285, cat: PC, desc: "Редакторы видео"} - - {id: 286, cat: PC, desc: "Видео- Аудио- конверторы"} - - {id: 287, cat: PC, desc: "Работа со звуком"} - - {id: 290, cat: PC, desc: "Разное (Программы для работы с мультимедиа и 3D)"} - - {id: 623, cat: PC, desc: "Мобильные телефоны"} - - {id: 624, cat: PC, desc: "КПК"} - - {id: 625, cat: PC, desc: "Навигаторы"} - # Library # Библиотека - - {id: 717, cat: Books, desc: "Аудиокниги"} - - {id: 738, cat: Books, desc: "Детектив / боевик"} - - {id: 739, cat: Books, desc: "Фантастика / фэнтези / мистика / ужасы"} - - {id: 836, cat: Books, desc: "Роман / комедийные произведения / приключения"} - - {id: 838, cat: Books, desc: "Классическая литература и современная проза"} - - {id: 837, cat: Books, desc: "Образование и Право"} - - {id: 843, cat: Books, desc: "Разное"} - - {id: 713, cat: Books, desc: "Художественная литература"} - - {id: 724, cat: Books, desc: "Детектив / боевик"} - - {id: 728, cat: Books, desc: "Фантастика / фэнтези / мистика / ужасы"} - - {id: 725, cat: Books, desc: "Роман / приключения / драма"} - - {id: 727, cat: Books, desc: "Комедийные произведения"} - - {id: 723, cat: Books, desc: "Историческая книга"} - - {id: 721, cat: Books, desc: "Классическая литература и современная проза"} - - {id: 722, cat: Books, desc: "Поэзия"} - - {id: 844, cat: Books, desc: "Разное"} - - {id: 718, cat: Books, desc: "Журналы"} - - {id: 763, cat: Books, desc: "Эротические журналы"} - - {id: 715, cat: Books, desc: "Компьютерная литература"} - - {id: 839, cat: Books, desc: "Научная и тех.литература"} - - {id: 719, cat: Books, desc: "Образование, Право и Психология"} - - {id: 846, cat: Books, desc: "Сексология"} - - {id: 840, cat: Books, desc: "Военное дело"} - - {id: 842, cat: Books, desc: "Спорт и физическое воспитание"} - - {id: 841, cat: Books, desc: "Хобби"} - - {id: 716, cat: Books, desc: "Дом и семейный очаг"} - - {id: 714, cat: Books, desc: "Книги для малышей и их родителей"} - - {id: 845, cat: Books, desc: "Комиксы"} - - {id: 741, cat: Books, desc: "Разное"} - # Release - groups # Релиз - группы - - {id: 378, cat: Other, desc: "Анонсы"} - # Miscellanea # Разное - - {id: 644, cat: Other, desc: "Разное"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_ymd - type: info - label: About the date - default: Before using this indexer please check on the Tapochek website that your account profile Y-m-d setting is Y-m-d H:i. If it is anything else then this indexer will generate a parse error and not return results. - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: login.php - method: form - form: form[action$="/login.php"] - captcha: - type: image - selector: img[src^="profile.php?mode=confirm&id="] - input: cfmcd - inputs: - login_username: "{{ .Config.username }}" - login_password: "{{ .Config.password }}" - autologin: 1 - redirect: index.php - selectorinputs: - cookie_test: - selector: input[name="cookie_test"] - attribute: value - optional: true - confirm_id: - selector: input[name="confirm_id"] - attribute: value - optional: true - sid: - selector: input[name="sid"]:not(input[id="dl-sid"]) - attribute: value - optional: true - error: - - selector: h4:contains("Вы ввели") - test: - path: index.php - selector: a[href="./login.php?logout=1"] - -search: - paths: - # https://tapochek.net/tracker.php?f=-1&gold=1&silver=1&o=1&s=2&tm=-1&sns=-1#results - - path: tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - nm: "{{ .Keywords }}" - o: "{{ .Config.sort }}" - s: "{{ .Config.type }}" - tm: -1 - sns: -1 - gold: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - keywordsfilters: - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: tr[id^="tor_"]:has(a[href^="./download.php?id="]) - - fields: - category: - selector: a.gen - attribute: href - filters: - - name: querystring - args: f - title: - selector: a.genmed, a.seedmed - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: replace - args: ["Кураж-Бамбей", "kurazh"] - - name: replace - args: ["Кубик в Кубе", "Kubik"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a.genmed, a.seedmed - attribute: href - download: - selector: a[href^="./download.php?id="] - attribute: href - size: - selector: td:nth-child(6) > u - date: - # unix - selector: td:last-child > u - seeders: - selector: td.seedmed - leechers: - selector: td.leechmed - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src="images/tor_gold.gif"]: 0 - img[src="images/tor_silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 - description: - selector: a.genmed, a.seedmed -# TorrentPier diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tasmanit.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tasmanit.yml deleted file mode 100644 index edbf9233..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tasmanit.yml +++ /dev/null @@ -1,170 +0,0 @@ ---- -id: tasmanit -name: Tasmanit -description: "Tasmanit.es is a AUSTRALIAN / NEW ZEALAND Private Torrent Tracker for AUS/NZ TV" -language: en-AU -type: private -encoding: UTF-8 -links: - - https://tasmanit.es/ - -caps: - categorymappings: - - {id: 72, cat: TV, desc: "Australian TV"} - - {id: 23, cat: TV, desc: "AU-AUTOGEN"} - - {id: 8, cat: TV, desc: "AU-Comedy"} - - {id: 18, cat: TV, desc: "AU-Crime"} - - {id: 14, cat: TV/Documentary, desc: "AU-Documentaries"} - - {id: 9, cat: TV, desc: "AU-Drama"} - - {id: 10, cat: TV, desc: "AU-Game/Quiz Shows"} - - {id: 12, cat: TV, desc: "AU-Kids Shows"} - - {id: 21, cat: TV, desc: "AU-Lifestyle"} - - {id: 11, cat: TV, desc: "AU-News/Current Affair"} - - {id: 76, cat: TV/Other, desc: "AU-Other"} - - {id: 82, cat: Audio, desc: "AU-Podcasts"} - - {id: 15, cat: TV, desc: "AU-Real Crime"} - - {id: 13, cat: TV, desc: "AU-Reality TV"} - - {id: 17, cat: TV, desc: "AU-Science/Medical"} - - {id: 80, cat: TV, desc: "AU-Soaps"} - - {id: 16, cat: TV, desc: "AU-Talkshows"} - - {id: 22, cat: Movies, desc: "AU-Telemovies"} - - {id: 73, cat: TV, desc: "New Zealand TV"} - - {id: 61, cat: TV, desc: "NZ-AUTOGEN"} - - {id: 47, cat: TV, desc: "NZ-Comedy"} - - {id: 58, cat: TV, desc: "NZ-Crime"} - - {id: 53, cat: TV/Documentary, desc: "NZ-Documentaries"} - - {id: 48, cat: TV, desc: "NZ-Dramas"} - - {id: 49, cat: TV, desc: "NZ-Game/Quiz Shows"} - - {id: 51, cat: TV, desc: "NZ-Kids Shows"} - - {id: 56, cat: TV, desc: "NZ-Lifestyle"} - - {id: 50, cat: TV, desc: "NZ-News/Current Affair"} - - {id: 60, cat: TV, desc: "NZ-Real Crime"} - - {id: 52, cat: TV, desc: "NZ-Reality TV"} - - {id: 54, cat: TV, desc: "NZ-Science/Medical"} - - {id: 81, cat: TV, desc: "NZ-Soaps"} - - {id: 57, cat: TV, desc: "NZ-Talkshows"} - - {id: 59, cat: Movies, desc: "NZ-Telemovies"} - - {id: 36, cat: TV, desc: "NZ-Other"} - - {id: 55, cat: TV, desc: "South African TV"} - - {id: 7, cat: TV/Other, desc: "TV-Other"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To keep records updated reguarly, all inactive accounts will be deleted after 60 days of inactivity." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div#content:has(h2:contains("Login Failed")) - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - method: post - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_name - # does not support multi category searching so defaulting to all. - category: 0 - include_dead_torrents: yes - sort: "{{ if .Config.freeleech }}free{{ else }}{{ .Config.sort }}{{ end }}" - order: "{{ if .Config.freeleech }}asc{{ else }}{{ .Config.type }}{{ end }}" - # does not return imdb link in results and t_genre does not return imdbid search results - keywordsfilters: - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%"] - - rows: - selector: table#sortabletable > tbody > tr:has(a[href]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }} - - fields: - title_default: - selector: td:nth-child(2) a - title: - selector: div.tooltip-content > div - optional: true - default: "{{ .Result.title_default }}" - category: - selector: a[href*="/browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/download.php?id="] - attribute: href - poster: - selector: img[src*="/torrents/images/"] - attribute: src - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - date: - selector: td:nth-child(2) > div:last-child - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd-MM-yyyy HH:mm" - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - img[src$="/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 0.5 -# TS Special Edition v.7.4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamctgame.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamctgame.yml deleted file mode 100644 index ce4a2acd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamctgame.yml +++ /dev/null @@ -1,267 +0,0 @@ ---- -id: teamctgame -replaces: - - casatorrent -name: Team CT Game -description: "Team CT Game (TCTG) is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://tctg.pm/ -legacylinks: - - https://teamctgame.co/ - - http://teamctgame.xyz/ - - https://teamctgame.xyz/ - - https://tctg.in/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 409, cat: Books/Comics, desc: "BD", default: true} - - {id: 410, cat: Books/Mags, desc: "Ebooks - Presse", default: true} - - {id: 411, cat: Audio/Audiobook, desc: "Ebooks - Audiobooks", default: true} - - {id: 412, cat: Books/EBook, desc: "Ebooks - Ebooks", default: true} - - {id: 413, cat: Movies/3D, desc: "Films - 3D", default: true} - - {id: 414, cat: Movies/HD, desc: "Films - 4K", default: true} - - {id: 415, cat: Movies/UHD, desc: "Films - 4K HDR", default: true} - - {id: 416, cat: Movies/UHD, desc: "Films - 4K Light", default: true} - - {id: 417, cat: Movies/UHD, desc: "Films - 4K Remux", default: true} - - {id: 418, cat: Movies, desc: "Films - Animation", default: true} - - {id: 419, cat: Movies/BluRay, desc: "Films - BDRip / BRRip", default: true} - - {id: 420, cat: Movies/DVD, desc: "Films - DVD-R", default: true} - - {id: 421, cat: Movies/DVD, desc: "Films - DVDRip", default: true} - - {id: 422, cat: Movies/BluRay, desc: "Films - Full Bluray", default: true} - - {id: 423, cat: Movies/HD, desc: "Films - HDTV", default: true} - - {id: 424, cat: Movies/HD, desc: "Films - HD 1080p", default: true} - - {id: 425, cat: Movies/HD, desc: "Films - HD 720p", default: true} - - {id: 426, cat: Movies/HD, desc: "Films - HDLiGHT / M-HD", default: true} - - {id: 427, cat: Movies/HD, desc: "Films - HDRiP", default: true} - - {id: 428, cat: Movies, desc: "Films - Pack", default: true} - - {id: 429, cat: Movies, desc: "Films - Remux", default: true} - - {id: 430, cat: TV, desc: "Films - TVRip", default: true} - - {id: 431, cat: Movies, desc: "Films - VO", default: true} - - {id: 432, cat: Movies, desc: "Films - VOSTFR", default: true} - - {id: 433, cat: Movies/WEB-DL, desc: "Films - Web", default: true} - - {id: 434, cat: Movies/HD, desc: "Films - x265", default: true} - - {id: 436, cat: Console, desc: "Jeux - Consoles", default: true} - - {id: 437, cat: PC/Games, desc: "Jeux - PC", default: true} - - {id: 438, cat: PC/0day, desc: "Logiciels - Windows", default: true} - - {id: 439, cat: Other, desc: "Logiciels - Graph / Wallpaper", default: true} - - {id: 440, cat: TV/HD, desc: "Series - 4K HDR", default: true} - - {id: 441, cat: TV/HD, desc: "Series TV - Pack HD", default: true} - - {id: 442, cat: TV/HD, desc: "Series TV - Episode HD", default: true} - - {id: 443, cat: TV/HD, desc: "Series TV - Pack HD VOSTFR", default: true} - - {id: 444, cat: TV/HD, desc: "Series TV - Episode HD VOSTFR", default: true} - - {id: 445, cat: TV/SD, desc: "Series TV - Pack SD", default: true} - - {id: 446, cat: TV/SD, desc: "Series TV - Episode SD", default: true} - - {id: 447, cat: TV/SD, desc: "Series TV - Pack SD VOSTFR", default: true} - - {id: 448, cat: TV/SD, desc: "Series TV - Episode SD VOSTFR", default: true} - - {id: 449, cat: TV/Anime, desc: "Series - Animés", default: true} - - {id: 450, cat: TV, desc: "Spectacles - Tous", default: true} - - {id: 451, cat: TV, desc: "TV - Emissions", default: true} - - {id: 452, cat: TV/Documentary, desc: "TV - Documentaires", default: true} - - {id: 453, cat: TV/Sport, desc: "TV - Sports", default: true} - - {id: 435, cat: XXX, desc: "Films - XXX", default: false} - - {id: 456, cat: XXX/Other, desc: "BD - XXX", default: false} - - {id: 457, cat: XXX/Other, desc: "ebook - xxx", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Any account with an Uploaded and Downloaded amount of 0 will be deleted if users have not logged in for more than 30 days
  2. Any account inactive for 60 days is automatically deleted
  3. Any account On Vacation and inactive for 180 days is automatically deleted.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - error: - - selector: td.embedded:has(h2:contains("Erreur")) # invalid captcha - - selector: td.embedded:has(h2:contains("Échec")) # invalid uid or pwd - message: - selector: td.text - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453] - - path: special.php - categories: [435, 456, 457] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_phase1: - selector: a[href^="details.php?id="] b - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - description: - case: - span[title="Autorisé"]: "Verified" - span[title="Pas examiné"]: "Unverified" - span[title="Refusé"]: "Banned" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 1.0 -# NexusPHP v1.7.30 2022-11-05 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamflix.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamflix.yml deleted file mode 100644 index 547f0d28..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamflix.yml +++ /dev/null @@ -1,242 +0,0 @@ ---- -id: teamflix -name: TeamFlix -description: "TeamFlix is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://tracker.teamflix.cc/ - -caps: - categorymappings: - - {id: 7, cat: Movies/Other, desc: "Animes"} - - {id: 8, cat: TV/Anime, desc: "AnimeSeries"} - - {id: 14, cat: TV/Other, desc: "Animation"} - - {id: 12, cat: Audio/Video, desc: "Concerts"} - - {id: 10, cat: TV/Documentary, desc: "Documentaires"} - - {id: 9, cat: TV, desc: "EmissionsTV"} - - {id: 1, cat: Movies, desc: "Films"} - - {id: 11, cat: TV, desc: "Spectacles"} - - {id: 2, cat: TV, desc: "SeriesTV"} - - {id: 13, cat: Books, desc: "Ebook"} - - {id: 4, cat: Console, desc: "Jeux Console"} - - {id: 3, cat: PC/Games, desc: "JeuxPC"} - - {id: 5, cat: PC/0day, desc: "Logiciels"} - - {id: 6, cat: Audio, desc: "Musique"} - - {id: 15, cat: Console/Other, desc: "Emulateur"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your TeamFlix account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: vfq - type: checkbox - label: Replace VFQ with FRENCH - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactivity of an account for a period of 60 consecutive days will automatically deactivate your account." - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title_phase1: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - title_vfq: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(VFQ)\\b", "FRENCH"] - title_phase2: - text: "{{ if .Config.vfq }}{{ .Result.title_vfq }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase3: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase3 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase3 }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" - minimumseedtime: - # 100 hours (as seconds = 100 x 60 x 60) - text: 360000 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamhd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamhd.yml deleted file mode 100644 index 134c474a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamhd.yml +++ /dev/null @@ -1,153 +0,0 @@ ---- -id: teamhd -name: TeamHD -description: "TeamHD is a RUSSIAN Private Torrent Tracker for HD MOVIES / TV" -language: ru-RU -type: private -encoding: UTF-8 -links: - - https://teamhd.org/ - -caps: - categorymappings: - - {id: 29, cat: Movies/HD, desc: "Movies Фильмы"} - - {id: 25, cat: TV/Anime, desc: "Cartoons Мультфильмы"} - - {id: 26, cat: Audio/Lossless, desc: "Hi-Res Audio"} - - {id: 27, cat: Other, desc: "Demo Демо"} - - {id: 28, cat: TV/Documentary, desc: "Documentary Документальное кино"} - - {id: 30, cat: Audio/Video, desc: "Music Video Музыкальное видео"} - - {id: 31, cat: TV/Sport, desc: "Sport Спорт"} - - {id: 32, cat: TV/HD, desc: "TV Show ТВ Шоу"} - - {id: 33, cat: TV/HD, desc: "Soaps Сериалы"} - - {id: 34, cat: TV/HD, desc: "Other"} - - {id: 35, cat: Movies, desc: "Content w/o subs Контент без перевода"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive users are regularly deleted. If you haven’t downloaded or distributed anything, or haven’t visited the site for 90 days, then you are a random guest on our resource. The tracker needs active users who know exactly why they came here. That’s why we regularly conduct automatic inactivity sweeps. If you need to be away from the tracker for a long time (business trip, personal circumstances, etc.), use 'parking' your account." - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href*="/logout.php"] - -search: - paths: - - path: browse - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # replace spaces with wildcard (percent symbol) as site's default search is exact string. - search: "{{ re_replace .Keywords \"[\\s]+\" \"%\" }}" - # 0 active, 1 incldead, 2 onlydead, 3 noseeders - incldead: 1 - # 0 all, 1 gold, 2 silver, 3 normal - free: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - headers: - User-Agent: ["{{ .Config.useragent }}"] - - keywordsfilters: - # remove the year from the titles as the site chokes on them during search https://github.com/Jackett/Jackett/issues/4397#issuecomment-623148789 - - name: re_replace - args: [" +(?:19|20)\\d{2} *$", ""] - # if season only then S01 -> Season 1 - - name: re_replace - args: ["(?i) S0(\\d{1})$", " Season $1"] - # if season only then S10 -> Season 10 - - name: re_replace - args: ["(?i) S(\\d{2})$", " Season $1"] - - rows: - selector: table.browse > tbody > tr - - fields: - download: - selector: a[href^="download.php?id="] - attribute: href - title: - selector: a[href^="/details/id"] - filters: - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - # convert Season xx -> Sxx - - name: re_replace - args: ["(?i)Season (\\d{2})", "S$1"] - # convert Season x -> S0x - - name: re_replace - args: ["(?i)Season (\\d{1})", "S0$1"] - # remove [] - - name: re_replace - args: ["[\\[|\\]]", ""] - details: - selector: a[href^="/details/id"] - attribute: href - category: - selector: a[href*="/browse/cat"] - attribute: href - filters: - - name: regexp - args: (\d+) - date: - selector: td > div > small - # auto adjusted by site account profile - filters: - - name: dateparse - args: "dd-MM-yyyy HH:mm" - seeders: - selector: td:nth-child(4) - filters: - - name: split - args: ["|", 0] - leechers: - selector: td:nth-child(4) - filters: - - name: split - args: ["|", 1] - grabs: - selector: td:nth-child(5) strong - size: - selector: td:nth-child(5) - remove: strong - downloadvolumefactor: - case: - ":root:has(div.justify-content-center:contains(\"Включен фрилич до\"))": 0 # Global Freeleech event - a[href^="/details/id"][style="color:#f2b101"]: 0 - a[href^="/details/id"][style="color:#828b8b"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - description: - selector: a[href^="/details/id"] -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamos.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamos.yml deleted file mode 100644 index 9b16719b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/teamos.yml +++ /dev/null @@ -1,193 +0,0 @@ ---- -id: teamos -name: TeamOS -description: "Team OS is a Private Torrent Tracker for SOFTWARE" -language: en-US -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.teamos.xyz/ -legacylinks: - - https://www.teamos-hkrg.com/ - - https://teamos-hkrg.com/ - - https://teamos.xyz/ - -caps: - categorymappings: - - {id: 18, cat: PC, desc: "Team-OS VIP Releases"} - - {id: 19, cat: PC, desc: "Windows 7 Releases"} - - {id: 20, cat: PC, desc: "Windows 7 Releases 32 Bit"} - - {id: 21, cat: PC, desc: "Windows 7 Releases 64 Bit"} - - {id: 22, cat: PC, desc: "Windows 7 Releases x86-x64 AIO"} - - {id: 24, cat: PC, desc: "Windows 8.1 Release"} - - {id: 25, cat: PC, desc: "Windows 8.1 Release 32 Bit"} - - {id: 26, cat: PC, desc: "Windows 8.1 Release 64 Bit"} - - {id: 27, cat: PC, desc: "Windows 8.1 Release x86-x64 AIO"} - - {id: 107, cat: PC, desc: "Windows 10 Releases"} - - {id: 109, cat: PC, desc: "Windows 10 Releases 64 Bit"} - - {id: 110, cat: PC, desc: "Windows 10 Releases x86-x64 AIO"} - - {id: 28, cat: PC, desc: "Operating Systems"} - - {id: 175, cat: PC, desc: "Windows AIO Operating Systems"} - - {id: 29, cat: PC, desc: "Windows XP"} - - {id: 30, cat: PC, desc: "Windows XP 32 Bit"} - - {id: 31, cat: PC, desc: "Windows XP 64 Bit"} - - {id: 32, cat: PC, desc: "Windows XP x86/64+AIO"} - - {id: 33, cat: PC, desc: "Windows 7"} - - {id: 34, cat: PC, desc: "Windows 7 32 Bit"} - - {id: 35, cat: PC, desc: "Windows 7 64 Bit"} - - {id: 36, cat: PC, desc: "Windows 7 x86/64+AIO"} - - {id: 37, cat: PC, desc: "Windows 8 & 8.1"} - - {id: 38, cat: PC, desc: "Windows 8 & 8.1 32 Bit"} - - {id: 39, cat: PC, desc: "Windows 8 & 8.1 64 Bit"} - - {id: 40, cat: PC, desc: "Windows 8 & 8.1 x86/64+AIO"} - - {id: 42, cat: PC, desc: "Windows 10"} - - {id: 43, cat: PC, desc: "Windows 10 (x86)"} - - {id: 44, cat: PC, desc: "Windows 10 (x64)"} - - {id: 45, cat: PC, desc: "Windows 10 (x86 & x64)"} - - {id: 157, cat: PC, desc: "Windows 11"} - - {id: 159, cat: PC, desc: "Windows 11 (x64)"} - - {id: 140, cat: PC/Mac, desc: "MacOS"} - - {id: 41, cat: PC, desc: "Windows Servers/Old Windows OS/Others"} - - {id: 46, cat: PC/ISO, desc: "Windows Untouched ISO(MSDN)"} - - {id: 47, cat: PC, desc: "Windows 7 Untouched (MSDN)"} - - {id: 48, cat: PC, desc: "Windows 7 32 Bit Untouched (MSDN)"} - - {id: 49, cat: PC, desc: "Windows 7 64 Bit Untouched (MSDN)"} - - {id: 50, cat: PC/ISO, desc: "Windows 7 x86/64+AIO Untouched ISO"} - - {id: 51, cat: PC, desc: "Windows 8 & 8.1 Untouched (MSDN)"} - - {id: 52, cat: PC, desc: "Windows 8 & 8.1 32 Bit Untouched (MSDN)"} - - {id: 53, cat: PC, desc: "Windows 8 & 8.1 64 Bit Untouched (MSDN)"} - - {id: 54, cat: PC/ISO, desc: "Windows 8 & 8.1 x86/64+AIO Untouched ISO"} - - {id: 58, cat: PC/ISO, desc: "Microsoft Office Multi-lang Untouch ISO"} - - {id: 59, cat: PC/ISO, desc: "x86-x64 bit Untouched Iso"} - - {id: 55, cat: PC, desc: "Windows Server 2003 (Untouched)"} - - {id: 56, cat: PC, desc: "Windows Server 2003 32 Bit Untouched"} - - {id: 57, cat: PC, desc: "Windows Server 2003 64 Bit Untouched"} - - {id: 132, cat: PC, desc: "Linux/Unix"} - - {id: 60, cat: PC/0day, desc: "Software Releases"} - - {id: 71, cat: PC/0day, desc: "Activators/ Crack/ patch/ keygen"} - - {id: 75, cat: PC/0day, desc: "Adobe Plugins"} - - {id: 62, cat: PC/Mobile-Android, desc: "Android Apps"} - - {id: 141, cat: PC/Mac, desc: "Mac Apps"} - - {id: 61, cat: PC/0day, desc: "Antivirus"} - - {id: 65, cat: PC/0day, desc: "Multimedia"} - - {id: 66, cat: PC/0day, desc: "Audio Apps"} - - {id: 68, cat: PC/0day, desc: "Photo Apps"} - - {id: 111, cat: PC/0day, desc: "3D Modeling"} - - {id: 67, cat: PC/0day, desc: "Video Apps"} - - {id: 76, cat: PC/0day, desc: "Customisation"} - - {id: 69, cat: PC/0day, desc: "Document Tools"} - - {id: 63, cat: PC/0day, desc: "Download Managers"} - - {id: 77, cat: PC/0day, desc: "Drivers and Necessary Tools"} - - {id: 64, cat: PC/0day, desc: "DVD Tools"} - - {id: 73, cat: PC/0day, desc: "Microsoft Office"} - - {id: 83, cat: PC/0day, desc: "Silent Apps"} - - {id: 72, cat: PC/0day, desc: "Other Softwares"} - - {id: 79, cat: PC/0day, desc: "Recovery Tools"} - - {id: 78, cat: PC/0day, desc: "Security Tools"} - - {id: 70, cat: PC/0day, desc: "Utility Tools"} - - {id: 74, cat: PC/0day, desc: "Web Tools"} - - {id: 81, cat: PC/0day, desc: "Developers"} - - {id: 82, cat: PC/0day, desc: "Designers"} - - {id: 84, cat: PC, desc: "TEAM OS Other Stuff Releases"} - - {id: 131, cat: PC/Mac, desc: "MAC Games"} - - {id: 88, cat: PC/Mobile-Android, desc: "Android Games"} - - {id: 85, cat: PC/Games, desc: "Games"} - - {id: 86, cat: Books/EBook, desc: "Ebooks"} - - {id: 87, cat: Other, desc: "Wallpapers, Themes & Screensavers"} - - {id: 89, cat: PC, desc: "Other Releases"} - - {id: 102, cat: Other, desc: "Post And Share"} - - {id: 100, cat: Other, desc: "Tutorials"} # undocumented - - {id: 103, cat: Other, desc: "Other Topic"} # undocumented - - {id: 166, cat: PC, desc: "Windows 64 Bit"} # undocumented - - modes: - search: [q] - book-search: [q] - -settings: - - name: info_novpn - type: info - label: VPN Not Allowed - default: "You may be banned if you use a VPN, proxy, TOR or data center to access the TeamOS web site.​ This mean all brands of VPNS, no exceptions!!" - # using cookie method because login page is dynamically generated using JS #5572 - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and read any mandatory notices that the site presents. You cannot browse torrents with unread notices outstanding." - -login: - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href="/account/"] - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - - paths: - # 25 results per page - - path: "torrents/?filename={{ .Keywords }}&freeleech={{ if .Config.freeleech }}1{{ else }}{{ end }}" - - path: "torrents/?filename={{ .Keywords }}&page=2&freeleech={{ if .Config.freeleech }}1{{ else }}{{ end }}" - - path: "torrents/?filename={{ .Keywords }}&page=3&freeleech={{ if .Config.freeleech }}1{{ else }}{{ end }}" - - path: "torrents/?filename={{ .Keywords }}&page=4&freeleech={{ if .Config.freeleech }}1{{ else }}{{ end }}" - - rows: - selector: tr.dataList-row:has(td.download) - - fields: - category: - selector: a[href^="/forums/"] - attribute: href - filters: - - name: regexp - args: "\\.(\\d+)\\/$" - title: - selector: a[href^="/threads/"] - details: - selector: a[href^="/threads/"] - attribute: href - download: - selector: td.download a - attribute: href - date: - selector: li.structItem-startDate a time - attribute: datetime - size: - selector: td:nth-last-child(4) - grabs: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - downloadvolumefactor: - case: - "span.label:contains(\"Freeleech\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 -# global MR is 0.4 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tekno3d.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tekno3d.yml deleted file mode 100644 index ccb40a13..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tekno3d.yml +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: tekno3d -name: TEKNO3D -description: "TEKNO3D is a Private Torrent Tracker for UHD MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://tracker.tekno3d.com/ - -caps: - categorymappings: - - {id: 29, cat: Audio, desc: "Audio Tracks"} - - {id: 26, cat: Movies/UHD, desc: "BETA UHD HDR-X"} - - {id: 32, cat: Movies, desc: "Dolby Vision"} - - {id: 28, cat: Movies, desc: "INTERNAL HDR-X"} - - {id: 37, cat: Movies, desc: "Member Grades"} - - {id: 37, cat: TV, desc: "Member Grades"} - - {id: 37, cat: Audio/Video, desc: "Member Grades"} - - {id: 31, cat: Other, desc: "YouTube HDR"} - - {id: 30, cat: Other, desc: "[SAMPLES] INTERNAL HDR-X"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: onlyfree - type: checkbox - label: Show only Free torrents - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info - type: info - label: Results Per Page - default: For best results, change the 'Torrents per page' setting to 100 on your profile. - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - use_ssl: 0 - perm_ssl: 0 - error: - - selector: h2 - message: - selector: table tr td.colhead2 - test: - path: browse.php - selector: a[href*="logout.php?hash_please="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # title, descr, genre, all - searchin: title - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - only_free: "{{ if .Config.onlyfree }}1{{ else }}0{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - keywordsfilters: - - name: re_replace - args: ["(\\w+)", "+$1"] # prepend + to each word - - rows: - selector: tr.highlight, tr.browse_color, tr.freeleech_color, tr[id^="kdescr"] - after: 1 - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: onmouseover - filters: - - name: split - args: [">", "1"] - - name: replace - args: ["\n"] - - name: prepend - args: "{{ if .Result.genre }}{{ .Result.genre }}
{{ else }}{{ end }}" - downloadvolumefactor: - case: - a.info:contains("FREE"): 0 - "*": 1 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thebrothers.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thebrothers.yml deleted file mode 100644 index de2d2599..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thebrothers.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -id: thebrothers -name: The Brothers -description: "The Brothers is a Private GERMAN tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://the-brothers.bounceme.net/ - -caps: - categorymappings: - # Klassiker - - {id: 157, cat: Movies, desc: "Klassiker All In"} - # Anime - - {id: 189, cat: TV/Anime, desc: "Anime Serie"} - - {id: 193, cat: TV/Anime, desc: "Anime Pack"} - - {id: 183, cat: TV/Anime, desc: "Anime HD"} - - {id: 184, cat: TV/Anime, desc: "Anime SD"} - - {id: 186, cat: TV/Anime, desc: "Anime 4K"} - # Kids - - {id: 146, cat: Movies, desc: "Kids Alles"} - # Movie - - {id: 114, cat: Movies/DVD, desc: "Movie DVD"} - - {id: 122, cat: Movies/UHD, desc: "Movie 4K"} - - {id: 124, cat: Movies, desc: "Movie Pack"} - - {id: 196, cat: Movies/UHD, desc: "Movie UHD"} - - {id: 28, cat: Movies/SD, desc: "Movie SD"} - - {id: 32, cat: Movies/HD, desc: "Movie HD"} - - {id: 26, cat: Movies/3D, desc: "Movie 3D"} - # Serien - - {id: 56, cat: TV/SD, desc: "Serien DVD"} - - {id: 57, cat: TV/HD, desc: "Serien HD"} - - {id: 59, cat: TV, desc: "Serien Pack"} - - {id: 60, cat: TV/SD, desc: "Serien SD"} - # Software - - {id: 9, cat: PC, desc: "App Linux"} - - {id: 10, cat: PC/Mac, desc: "App Mac"} - - {id: 11, cat: PC/Mobile-Other, desc: "App Other"} - - {id: 12, cat: PC/0day, desc: "App Win"} - # Game - - {id: 120, cat: Console/PS3, desc: "Game PlayStation"} - - {id: 16, cat: Console, desc: "Game Wimmelbild"} - - {id: 17, cat: PC/Games, desc: "Game PC"} - - {id: 20, cat: Console/PSP, desc: "Game PSP"} - - {id: 22, cat: Console/Wii, desc: "Game Wii"} - - {id: 24, cat: Console/XBox, desc: "Game XBOX"} - # Music - - {id: 48, cat: Audio, desc: "Music Album"} - - {id: 49, cat: Audio, desc: "Music Charts"} - - {id: 52, cat: Audio, desc: "Music Pack"} - - {id: 54, cat: Audio, desc: "Music Single"} - - {id: 55, cat: Audio/Video, desc: "Music Video"} - - {id: 125, cat: Audio, desc: "Music Mixe"} - - {id: 126, cat: Audio, desc: "Music Audio"} - - {id: 148, cat: Audio, desc: "Music Discography"} - # Sport - - {id: 61, cat: TV/Sport, desc: "Sport Other"} - - {id: 130, cat: TV/Sport, desc: "Sport WWE"} - # Sonstiges - - {id: 144, cat: TV/Documentary, desc: "Sonstiges Doku"} - - {id: 131, cat: Books/Mags, desc: "Sonstiges Zeitungen"} - - {id: 63, cat: Audio/Audiobook, desc: "Sonstiges A-Book"} - - {id: 96, cat: Books/EBook, desc: "Sonstiges E-Book"} - - {id: 69, cat: Books, desc: "Sonstiges Tutorial"} - - {id: 71, cat: Books/Other, desc: "Sonstiges Other"} - # Internt'l - - {id: 98, cat: Movies/HD, desc: "Internt'l Movie HD"} - - {id: 99, cat: TV/HD, desc: "Internt'l Serie HD"} - - {id: 102, cat: TV/SD, desc: "Internt'l Serie SD"} - - {id: 103, cat: Movies/SD, desc: "Internt'l Movie SD"} - - {id: 104, cat: Other, desc: "Internt'l Misc"} - - {id: 105, cat: TV/Documentary, desc: "Internt'l Doku"} - - {id: 106, cat: Books/EBook, desc: "Internt'l E-Book"} - - {id: 107, cat: Audio/Audiobook, desc: "Internt'l A-Book"} - - {id: 115, cat: Movies/DVD, desc: "Internt'l DVD"} - # XXX - - {id: 73, cat: XXX/x264, desc: "XXX HD"} - - {id: 75, cat: XXX/Pack, desc: "XXX Pack"} - - {id: 76, cat: XXX/SD, desc: "XXX SD"} - - {id: 113, cat: XXX/DVD, desc: "XXX DVD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Anzahl der Torrents beim Durchsuchen Torrents per page: setting to 60 on your account profile. The default is 15. - -login: - path: login.php - method: form - form: form[action="/login.php"] - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - pin: "{{ .Config.pin }}" - error: - - selector: div#login_error - test: - path: index.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: a[href^="download_ssl.php?torrent="] - attribute: href - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both - blah: 0 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: div.selection_wrap - fields: - category_p1: - selector: div.kat_cat_pic_name - category_p2: - selector: div.kat_cat_pic_name_b - categorydesc: - text: "{{ .Result.category_p1 }} {{ .Result.category_p2 }}" - title: - selector: a.selection_a - filters: - - name: re_replace - args: ["^\\[[\\w ]*\\]\\s?", ""] - details: - selector: a.selection_a - attribute: href - download: - selector: a.selection_a - attribute: href - poster: - selector: div[id^="details"] img - attribute: src - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - date_day: - # Heute 13:30:04 - # Gestern 09:10:10 - selector: div.selection_unter_ab:not(:contains(".")) - optional: true - filters: - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 30.02.2018 um 23:12:50 - selector: div.selection_unter_ab:contains(".") - optional: true - filters: - - name: replace - args: [" um", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - downloadvolumefactor: - case: - ":root:has(div.onlyup)": 0 # GLobal Freeleech event - "*": 1 - uploadvolumefactor: - text: 1 -# global MR is 0.7 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.7 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# 3xT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thecrazyones.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thecrazyones.yml deleted file mode 100644 index 775ec88d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thecrazyones.yml +++ /dev/null @@ -1,213 +0,0 @@ ---- -id: thecrazyones -name: The Crazy Ones -description: "The Crazy Ones is a Private GERMAN Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://thecrazyones.hopto.org/ - -caps: - categorymappings: - # Anime - - {id: 123, cat: TV/Anime, desc: "Anime Alles"} - # Movie - - {id: 122, cat: Movies/UHD, desc: "Movie 4K"} - - {id: 114, cat: Movies/DVD, desc: "Movie DVD"} - - {id: 118, cat: Movies, desc: "Movie Klassiker"} - - {id: 124, cat: Movies, desc: "Movie Pack"} - - {id: 28, cat: Movies/SD, desc: "Movie SD"} - - {id: 32, cat: Movies/HD, desc: "Movie HD"} - - {id: 26, cat: Movies/3D, desc: "Movie 3D"} - # Serien - - {id: 152, cat: TV/SD, desc: "Serien DVD"} - - {id: 139, cat: TV/HD, desc: "Serien HD"} - - {id: 59, cat: TV, desc: "Serien Pack"} - - {id: 57, cat: TV/SD, desc: "Serien SD"} - - {id: 60, cat: TV, desc: "Serien Klassiker"} - # Doku - - {id: 121, cat: TV/Documentary, desc: "Doku Pack"} - - {id: 14, cat: TV/Documentary, desc: "Doku HD"} - - {id: 15, cat: TV/Documentary, desc: "Doku SD"} - # Software - - {id: 12, cat: PC/0day, desc: "App Win"} - - {id: 11, cat: PC/Mobile-Other, desc: "App Other"} - - {id: 10, cat: PC/Mac, desc: "App Mac"} - - {id: 9, cat: PC, desc: "App Linux"} - # Game - - {id: 120, cat: Console/PS3, desc: "Game PlayStation"} - - {id: 16, cat: Console, desc: "Game Wimmelbild"} - - {id: 17, cat: PC/Games, desc: "Game PC"} - - {id: 20, cat: Console/PSP, desc: "Game PSP"} - - {id: 22, cat: Console/Wii, desc: "Game Wii"} - - {id: 24, cat: Console/XBox, desc: "Game XBOX"} - # Music - - {id: 48, cat: Audio, desc: "Music Album"} - - {id: 49, cat: Audio, desc: "Music Charts"} - - {id: 52, cat: Audio, desc: "Music Pack"} - - {id: 54, cat: Audio, desc: "Music Single"} - - {id: 55, cat: Audio/Video, desc: "Music Video"} - - {id: 125, cat: Audio, desc: "Music Mixe"} - - {id: 126, cat: Audio, desc: "Music Audio"} - # Sport - - {id: 61, cat: TV/Sport, desc: "Sport Other"} - # Sonstiges - - {id: 63, cat: Audio/Audiobook, desc: "Sonstiges A-Book"} - - {id: 96, cat: Books/EBook, desc: "Sonstiges E-Book"} - - {id: 69, cat: Books, desc: "Sonstiges Tutorial"} - - {id: 71, cat: Books/Other, desc: "Sonstiges Other"} - # Internt'l - - {id: 102, cat: TV/SD, desc: "Internt'l Serie SD"} - - {id: 98, cat: Movies/HD, desc: "Internt'l Movie HD"} - - {id: 99, cat: TV/HD, desc: "Internt'l Serie HD"} - - {id: 105, cat: TV/Documentary, desc: "Internt'l Doku"} - - {id: 103, cat: Movies/SD, desc: "Internt'l Movie SD"} - - {id: 104, cat: Other, desc: "Internt'l Misc"} - - {id: 106, cat: Books/EBook, desc: "Internt'l E-Book"} - - {id: 107, cat: Audio/Audiobook, desc: "Internt'l A-Book"} - - {id: 115, cat: Movies/DVD, desc: "Internt'l DVD"} - # XXX - - {id: 73, cat: XXX/x264, desc: "XXX HD"} - - {id: 75, cat: XXX/Pack, desc: "XXX Pack"} - - {id: 76, cat: XXX/SD, desc: "XXX SD"} - - {id: 113, cat: XXX/DVD, desc: "XXX DVD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Anzahl der Torrents beim Durchsuchen Torrents per page: setting to 60 on your account profile. The default is 15. - -login: - path: login.php - method: form - form: form[action="/login.php"] - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - pin: "{{ .Config.pin }}" - error: - - selector: div#login_error - test: - path: index.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: a[href^="download_ssl.php?torrent="] - attribute: href - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both - blah: 0 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: div.selection_wrap - fields: - category_p1: - selector: div.kat_cat_pic_name - category_p2: - selector: div.kat_cat_pic_name_b - categorydesc: - text: "{{ .Result.category_p1 }} {{ .Result.category_p2 }}" - title: - selector: a.selection_a - filters: - - name: re_replace - args: ["^\\[[\\w ]*\\]\\s?", ""] - details: - selector: a.selection_a - attribute: href - download: - selector: a.selection_a - attribute: href - poster: - selector: div[id^="details"] img - attribute: src - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - date_day: - # Heute 13:30:04 - # Gestern 09:10:10 - selector: div.selection_unter_ab:not(:contains(".")) - optional: true - filters: - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 30.02.2018 um 23:12:50 - selector: div.selection_unter_ab:contains(".") - optional: true - filters: - - name: replace - args: [" um", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - downloadvolumefactor: - case: - ":root:has(div.onlyup)": 0 # GLobal Freeleech event - "*": 1 - uploadvolumefactor: - text: 1 -# global MR is 0.7 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.7 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# 3xT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theempire.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theempire.yml deleted file mode 100644 index 0e3240e5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theempire.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: theempire -name: The Empire -description: "TheEmpire (TE) is a Private Torrent Tracker for COMMONWEALTH TV / RADIO" -language: en-US -type: private -encoding: UTF-8 -links: - - https://theempire.click/ -legacylinks: - - http://theempire.click/ - -caps: - categorymappings: - - {id: 55, cat: TV, desc: "Adverts"} - - {id: 60, cat: TV, desc: "Educational"} - - {id: 63, cat: TV, desc: "Game Shows"} - - {id: 67, cat: TV, desc: "Music"} - - {id: 72, cat: TV, desc: "QuizComedy"} - - {id: 77, cat: TV, desc: "Special Events"} - - {id: 81, cat: TV, desc: "Trains & Planes"} - - {id: 54, cat: TV, desc: "Arts & Culture"} - - {id: 61, cat: TV, desc: "Entertainment"} - - {id: 53, cat: TV, desc: "Gardening"} - - {id: 68, cat: TV, desc: "Mystery & Crime Fiction"} - - {id: 73, cat: TV, desc: "Radio"} - - {id: 78, cat: TV, desc: "Special Interest"} - - {id: 82, cat: TV, desc: "Travel"} - - {id: 56, cat: TV, desc: "Comedy"} - - {id: 85, cat: TV, desc: "Euro-Noir"} - - {id: 64, cat: TV, desc: "Kids"} - - {id: 69, cat: TV, desc: "News"} - - {id: 74, cat: TV, desc: "Reality"} - - {id: 79, cat: TV, desc: "Sport"} - - {id: 83, cat: TV, desc: "Wildlife & Nature"} - - {id: 58, cat: TV, desc: "Documentary"} - - {id: 57, cat: TV, desc: "Fly on the Wall/Lifestyle"} - - {id: 65, cat: TV, desc: "Magazine"} - - {id: 70, cat: TV, desc: "Occult & Horror"} - - {id: 75, cat: TV, desc: "Sci-Fi"} - - {id: 84, cat: TV, desc: "Style & Fashion"} - - {id: 62, cat: TV, desc: "Food Drink & Cooking"} - - {id: 66, cat: TV, desc: "Motoring"} - - {id: 71, cat: TV, desc: "Property"} - - {id: 76, cat: TV, desc: "Soaps"} - - {id: 200, cat: TV, desc: "Soaps : Classic"} - - {id: 80, cat: TV, desc: "Talkshow"} - - {id: 59, cat: TV, desc: "Drama"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from the TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - keywordsfilters: - # if searching for season packs switch S01 to Series 1 - - name: re_replace - args: ["(?i)\\bS0(\\d{1,2})\\b", "Series $1"] - - name: re_replace - args: ["(?i)\\bS(\\d{1,3})\\b", "Series $1"] - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - filters: - - name: re_replace - args: ["(?i)\\bSeries[\\s]+(\\d+)\\b", "S$1"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - files: - selector: td:nth-child(4) - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.97 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thefallingangels.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thefallingangels.yml deleted file mode 100644 index 4edc8e96..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thefallingangels.yml +++ /dev/null @@ -1,226 +0,0 @@ ---- -id: thefallingangels -name: The Falling Angels -description: "The Falling Angels (TFA) is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://tfa.tf/ - -caps: - categorymappings: - - {id: 107, cat: PC/0day, desc: "Apps Windows"} - - {id: 132, cat: PC/Mac, desc: "Apps Mac-OSX"} - - {id: 105, cat: PC/Mobile-Other, desc: "Apps Other"} - - {id: 116, cat: PC/Games, desc: "Games PC"} - - {id: 155, cat: Console/NDS, desc: "Games Nintendo DS"} - - {id: 134, cat: Console, desc: "Games Switch"} - - {id: 138, cat: Console/Wii, desc: "Games Nintendo Wii"} - - {id: 124, cat: Console/PSP, desc: "Games PSP / PS 1-4"} - - {id: 139, cat: Console/XBox, desc: "Games XBOX"} - - {id: 140, cat: Movies/HD, desc: "Movies HD"} - - {id: 141, cat: Movies/SD, desc: "Movies SD"} - - {id: 125, cat: Movies/UHD, desc: "Movies Ultra HD"} - - {id: 104, cat: Movies/Other, desc: "Movies Packs"} - - {id: 100, cat: Movies, desc: "Movies Horror"} - - {id: 142, cat: Movies/DVD, desc: "Movies DVD"} - - {id: 143, cat: Movies/BluRay, desc: "Movies Bluray"} - - {id: 144, cat: Movies/DVD, desc: "Movies S-DVD"} - - {id: 133, cat: Movies/Foreign, desc: "Movies Englisch"} - - {id: 101, cat: Movies/HD, desc: "TFA AVCHD's"} - - {id: 103, cat: Movies/DVD, desc: "TFA Special DVD's"} - - {id: 108, cat: TV/Documentary, desc: "Dokus HD"} - - {id: 113, cat: TV/Documentary, desc: "Dokus SD"} - - {id: 154, cat: TV/Documentary, desc: "Dokus Pack"} - - {id: 149, cat: TV/HD, desc: "Serien HD"} - - {id: 151, cat: TV/SD, desc: "Serien SD"} - - {id: 167, cat: TV/UHD, desc: "Serien Ultra HD"} - - {id: 150, cat: TV/Other, desc: "Serien Pack"} - - {id: 136, cat: TV/Foreign, desc: "Serien Englisch"} - - {id: 152, cat: TV/Sport, desc: "Sport HD"} - - {id: 153, cat: TV/Sport, desc: "Sport SD"} - - {id: 135, cat: TV/Sport, desc: "Sport Ultra HD"} - - {id: 106, cat: TV/Sport, desc: "Sport Pack"} - - {id: 147, cat: Audio/MP3, desc: "Music MP3"} - - {id: 145, cat: Audio/Lossless, desc: "Music Flac"} - - {id: 137, cat: Audio, desc: "Music M4A"} - - {id: 109, cat: Audio, desc: "Music Discographie"} - - {id: 146, cat: Audio/Other, desc: "Music Pack"} - - {id: 148, cat: Audio/Video, desc: "Music Video"} - - {id: 157, cat: Movies/HD, desc: "Kids Movies HD"} - - {id: 158, cat: Movies/SD, desc: "Kids Movies SD"} - - {id: 161, cat: TV/HD, desc: "Kids Serien HD"} - - {id: 162, cat: TV/SD, desc: "Kids Serien SD"} - - {id: 112, cat: Audio/Audiobook, desc: "Kids Hörbuch"} - - {id: 115, cat: Books/Mags, desc: "E-Books Zeitschriften"} - - {id: 114, cat: Books/Comics, desc: "E-Books Comics"} - - {id: 117, cat: Books/EBook, desc: "E-Books Bücher"} - - {id: 156, cat: Books, desc: "E-Books Packs"} - - {id: 130, cat: Books, desc: "E-Books FSK 18"} - - {id: 159, cat: Movies/Other, desc: "Anime Movies HD"} - - {id: 160, cat: Movies/Other, desc: "Anime Movies SD"} - - {id: 168, cat: TV/Anime, desc: "Anime Serien HD"} - - {id: 169, cat: TV/Anime, desc: "Anime Serien SD"} - - {id: 170, cat: TV/Anime, desc: "Anime Serien Pack"} - - {id: 129, cat: TV/Anime, desc: "Anime Sonstiges"} - - {id: 118, cat: Other, desc: "Other Mobile"} - - {id: 119, cat: Other, desc: "Other Hörbücher"} - - {id: 123, cat: Other, desc: "Other Pics"} - - {id: 120, cat: XXX, desc: "XXX Movies HD"} - - {id: 121, cat: XXX, desc: "XXX Movies SD"} - - {id: 165, cat: XXX, desc: "XXX Ultra HD"} - - {id: 122, cat: XXX, desc: "XXX Movies Pack"} - - {id: 131, cat: XXX, desc: "XXX Hentai"} - - {id: 164, cat: XXX, desc: "XXX VR Porn"} - - {id: 166, cat: XXX, desc: "XXX Games"} - - {id: 110, cat: XXX, desc: "XXX Pics"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts will be permanently deleted from the database after 125 days! Seeding torrents does not count as activity - the only thing that counts is logging in again! You can extend this period to 250 days by setting your account to Parked. You can do this under Edit Profile." - -login: - path: login.php - method: form - form: form[method="post"] - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - pin: "{{ .Config.pin }}" - error: - - selector: div#login_error - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both - blah: 0 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - - rows: - selector: "div.selection_wrap{{ if .Config.freeleech }}:has(div[onmouseover=\"return overlib('OnlyUp: Ja');\"]){{ else }}{{ end }}" - - fields: - category_p1: - selector: div.kat_cat_pic_name - category_p2: - selector: div.kat_cat_pic_name_b - categorydesc: - text: "{{ .Result.category_p1 }} {{ .Result.category_p2 }}" - title: - selector: a.selection_a - filters: - - name: re_replace - args: ["^\\[[\\w ]*\\]\\s?", ""] - details: - selector: a.selection_a - attribute: href - download_default: - selector: a[href^="download.php?torrent="] - attribute: href - optional: true - download: - selector: a[href^="download_ssl.php?torrent="] - attribute: href - optional: true - default: "{{ .Result.download_default }}" - poster: - selector: div[id^="details"] img - attribute: src - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - date_day: - # Heute 13:30:04 - # Gestern 09:10:10 - selector: div.selection_unter_ab:not(:contains(".")) - optional: true - filters: - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 30.02.2018 um 23:12:50 - selector: div.selection_unter_ab:contains(".") - optional: true - filters: - - name: replace - args: [" um", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - description: - selector: selection_unter_af - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - downloadvolumefactor: - case: - "div[onmouseover=\"return overlib('OnlyUp: Ja');\"]": 0 - "div[onmouseover=\"return overlib('Freeleech: Ja');\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "div[onmouseover=\"return overlib('Freeleech: Ja');\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# 3xT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thegeeks.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thegeeks.yml deleted file mode 100644 index c46b430f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thegeeks.yml +++ /dev/null @@ -1,210 +0,0 @@ ---- -id: thegeeks -name: The Geeks -description: "The Geeks is a Private Torrent Tracker for Technology E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -certificates: - - d27e03f320cd5d0b6f74718659bd13b0b3ad6689 # Expired 21st Feb 2026 -links: - - https://thegeeks.click/ - -caps: - categorymappings: - - {id: 212, cat: Other, desc: "AudioBook : Fiction"} - - {id: 213, cat: Other, desc: "AudioBook : Non-Fiction"} - - {id: 71, cat: Other, desc: "Docu : Antiques / Collecting"} - - {id: 82, cat: Other, desc: "Docu : Architecture/Building"} - - {id: 72, cat: Other, desc: "Docu : Astronomy / Space"} - - {id: 85, cat: Other, desc: "Docu : Childrens Ed"} - - {id: 38, cat: Other, desc: "Docu : Crime/ Investigation"} - - {id: 59, cat: Other, desc: "Docu : Earth / Environment"} - - {id: 18, cat: Other, desc: "Docu : Engineering"} - - {id: 4, cat: Other, desc: "Docu : Fine/Visual Arts"} - - {id: 39, cat: Other, desc: "Docu : FlyOnWall Docus"} - - {id: 216, cat: Other, desc: "Docu : FlyOnWall Medical"} - - {id: 84, cat: Other, desc: "Docu : FrontLine"} - - {id: 36, cat: Other, desc: "Docu : Gardening/Agriculture"} - - {id: 58, cat: Other, desc: "Docu : History - Biographies"} - - {id: 55, cat: Other, desc: "Docu : History - Civilization"} - - {id: 31, cat: Other, desc: "Docu : History - Misc"} - - {id: 56, cat: Other, desc: "Docu : History - War/Politics"} - - {id: 35, cat: Other, desc: "Docu : Home / Property"} - - {id: 78, cat: Other, desc: "Docu : Horizon"} - - {id: 69, cat: Other, desc: "Docu : Misc"} - - {id: 75, cat: Other, desc: "Docu : Nature"} - - {id: 54, cat: Other, desc: "Docu : News/World Reports"} - - {id: 83, cat: Other, desc: "Docu : Nova"} - - {id: 27, cat: Other, desc: "Docu : Paleontology"} - - {id: 67, cat: Other, desc: "Docu : Pets/Animal Keeping"} - - {id: 53, cat: Other, desc: "Docu : Social Experiment"} - - {id: 32, cat: Other, desc: "Docu : Travel / Culture"} - - {id: 80, cat: Other, desc: "Docu : Travelogues"} - - {id: 34, cat: Other, desc: "Docu : Vehicles/Transport"} - - {id: 207, cat: Other, desc: "Business : Economics"} - - {id: 211, cat: Other, desc: "Business : Investing"} - - {id: 208, cat: Other, desc: "Business : Marketing"} - - {id: 209, cat: Other, desc: "Business : Startup/Dev"} - - {id: 28, cat: Other, desc: "Business :Misc"} - - {id: 214, cat: Other, desc: "Comp: Artificial Intelligence"} - - {id: 40, cat: Other, desc: "Comp: Certification Courses"} - - {id: 44, cat: Other, desc: "Comp: Digital Audio/Video"} - - {id: 48, cat: Other, desc: "Comp: Games Dev/Guides"} - - {id: 3, cat: Other, desc: "Comp: Graphics "} - - {id: 42, cat: Other, desc: "Comp: Lang/DBs"} - - {id: 49, cat: Other, desc: "Comp: Network/Hardware"} - - {id: 43, cat: Other, desc: "Comp: Operating Systems"} - - {id: 46, cat: Other, desc: "Comp: Security/Encryption"} - - {id: 52, cat: Other, desc: "Comp: Software Training"} - - {id: 41, cat: Other, desc: "Comp: Theory/Ref/Mags"} - - {id: 47, cat: Other, desc: "Comp: Web Development"} - - {id: 68, cat: Other, desc: "Crafts/Jewelry"} - - {id: 21, cat: Other, desc: "DIY / Workshop"} - - {id: 25, cat: Other, desc: "Exam Prep / Education"} - - {id: 9, cat: Other, desc: "Food/Cooking/Nutrition"} - - {id: 37, cat: Other, desc: "Game Shows / Quiz Shows"} - - {id: 2, cat: Other, desc: "Games: Cards/Tabletop/etc"} - - {id: 23, cat: Other, desc: "Hobbies: Misc"} - - {id: 1, cat: Other, desc: "Languages / Linguistics"} - - {id: 20, cat: Other, desc: "Law & Justice"} - - {id: 30, cat: Other, desc: "Literature"} - - {id: 16, cat: Other, desc: "Magic & Illusion"} - - {id: 206, cat: Other, desc: "Native American Studies"} - - {id: 201, cat: Other, desc: "Occultism: Academic / Referenc"} - - {id: 210, cat: Other, desc: "Occultism: Bashar"} - - {id: 202, cat: Other, desc: "Occultism: Mythology Folklore"} - - {id: 203, cat: Other, desc: "Paranormal"} - - {id: 204, cat: Other, desc: "Parapsychology"} - - {id: 11, cat: Other, desc: "Political Studies"} - - {id: 61, cat: Other, desc: "Science: Biology"} - - {id: 77, cat: Other, desc: "Science: Chemistry"} - - {id: 12, cat: Other, desc: "Science: Math/Statistics"} - - {id: 10, cat: Other, desc: "Science: Medicine/Health "} - - {id: 29, cat: Other, desc: "Science: Philosophy"} - - {id: 76, cat: Other, desc: "Science: Physics"} - - {id: 14, cat: Other, desc: "Science: Psych/Sociolgy"} - - {id: 215, cat: Other, desc: "Sexuality/Seductn/Body Img"} - - {id: 22, cat: Other, desc: "Sports/Exercise/Outdoors"} - - {id: 24, cat: Other, desc: "Stock Media"} - - {id: 33, cat: Other, desc: "Style & Fashion"} - - {id: 73, cat: Other, desc: "Docu : Survivalism"} - - {id: 60, cat: Other, desc: "Docu : The Arts: Acting/ Filmmaking"} - - {id: 217, cat: Other, desc: "Docu : The Arts: Dance/Singing/Voice"} - - {id: 26, cat: Other, desc: "Docu : The Arts: Music History/Theory"} - - {id: 15, cat: Other, desc: "Docu : The Arts: Music Learn/Courses"} - - {id: 45, cat: Other, desc: "Docu : The Arts: Photography"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.98 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thekitchen.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thekitchen.yml deleted file mode 100644 index 3975a544..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thekitchen.yml +++ /dev/null @@ -1,172 +0,0 @@ ---- -id: thekitchen -name: The Kitchen -description: "TheKitchen (TK) is a Private Torrent Tracker for COOKING / CUISINES / RECIPES" -language: en-US -type: private -encoding: UTF-8 -links: - - https://thekitchen.click/ - -caps: - categorymappings: - - {id: 240, cat: Other, desc: "Business : Catering"} - - {id: 221, cat: Other, desc: "Cuisine : Indian"} - - {id: 215, cat: Other, desc: "Food : Dairy Free"} - - {id: 232, cat: Other, desc: "Food : Meat"} - - {id: 257, cat: Other, desc: "Franchise : Bake Off/Baking Sh"} - - {id: 241, cat: Other, desc: "Business : Food Prep"} - - {id: 222, cat: Other, desc: "Cuisine : Italian"} - - {id: 251, cat: Other, desc: "Food : Dating/Romance"} - - {id: 234, cat: Other, desc: "Food : Organic Food"} - - {id: 255, cat: Other, desc: "Franchise : Hells Kitchen"} - - {id: 243, cat: Other, desc: "Cuisine : African"} - - {id: 223, cat: Other, desc: "Cuisine : Mexican"} - - {id: 225, cat: Other, desc: "Food : Desserts/Pastries"} - - {id: 235, cat: Other, desc: "Food : Seasonal"} - - {id: 254, cat: Other, desc: "Franchise : Iron Chef"} - - {id: 216, cat: Other, desc: "Cuisine : American"} - - {id: 224, cat: Other, desc: "Cuisine : Middle Eastern"} - - {id: 228, cat: Other, desc: "Food : Fish"} - - {id: 238, cat: Other, desc: "Food : Soups/Salads/Starter"} - - {id: 253, cat: Other, desc: "Franchise : Masterchef"} - - {id: 217, cat: Other, desc: "Cuisine : Asian"} - - {id: 214, cat: Other, desc: "Cuisine : Spanish"} - - {id: 230, cat: Other, desc: "Food : Fruit"} - - {id: 236, cat: Other, desc: "Food : Sugar Free"} - - {id: 256, cat: Other, desc: "Franchise : Top Chef"} - - {id: 249, cat: Other, desc: "Cuisine : Australasia"} - - {id: 226, cat: Other, desc: "Drinks : Alchohol"} - - {id: 231, cat: Other, desc: "Food : Health Food/Nutrition"} - - {id: 229, cat: Other, desc: "Food : Tech/Hygiene"} - - {id: 252, cat: TV, desc: "TV : Chef/Food Ent/Drama"} - - {id: 218, cat: Other, desc: "Cuisine : British"} - - {id: 227, cat: Other, desc: "Drinks : Non-Alchohol"} - - {id: 246, cat: Other, desc: "Food : Herbs/Spice/Fungi"} - - {id: 233, cat: Other, desc: "Food : Vegan"} - - {id: 242, cat: TV, desc: "TV : Chef/Food Shows"} - - {id: 219, cat: Other, desc: "Cuisine : European(other)"} - - {id: 239, cat: Other, desc: "Food : Bread"} - - {id: 250, cat: Other, desc: "Food : Kids"} - - {id: 248, cat: Other, desc: "Food : Vegetables"} - - {id: 245, cat: TV, desc: "TV : Chef/Food Travel"} - - {id: 220, cat: Other, desc: "Cuisine : French"} - - {id: 247, cat: Other, desc: "Food : Cooking On A Budget"} - - {id: 259, cat: Other, desc: "Food : Kosher"} - - {id: 244, cat: Other, desc: "Food : Vegetarian"} - - {id: 237, cat: TV, desc: "TV : Reality Food Shows"} - - modes: - search: [q] - tv-search: [q, season, ep] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from the TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - files: - selector: td:nth-child(4) - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.98 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theleachzone-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theleachzone-api.yml deleted file mode 100644 index 951ded40..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theleachzone-api.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: theleachzone-api -name: TheLeachZone (API) -description: "The Leach Zone (TLZ) is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://tlzdigital.com/ -legacylinks: - - https://tlz.digital/ - -caps: - categorymappings: - - {id: 2, cat: TV, desc: "TV"} - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 3, cat: Audio, desc: "Music"} - - {id: 4, cat: Books, desc: "Books"} - - {id: 5, cat: Console, desc: "Games"} - - {id: 6, cat: PC, desc: "Application"} - - {id: 7, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your TLZ account Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - inputs: {} # TODO: remove in v10 - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title: - selector: name - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - - name: replace - args: ["https://dummyimage.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)^None$", ""] - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - 0: "{{ .False }}" - 1: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - files: - selector: num_file - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - downloadvolumefactor: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - uploadvolumefactor: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - minimumratio: - text: 1.0 - minimumseedtime: - # 4 days (as seconds = 4 x 24 x 60 x 60) - text: 345600 -# json UNIT3D 7.2.5 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/themixingbowl.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/themixingbowl.yml deleted file mode 100644 index 634cc21a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/themixingbowl.yml +++ /dev/null @@ -1,143 +0,0 @@ ---- -id: themixingbowl -name: themixingbowl -description: "themixingbowl (TMB) is a Semi-Private Torrent Tracker for DJ Music mixes" -language: en-US -type: semi-private -encoding: UTF-8 -links: - - https://tmb.dj/ - - https://themixingbowl.org/ - -caps: - categorymappings: - - {id: 1, cat: Audio, desc: Music} - - modes: - search: [q] - music-search: [q, artist] - -settings: - - name: info_nojs - type: info - label: JavaScript - default: This indexer requires that the web site not use JS when searching.
Access your account Preferences on the web site and change the set JavaScript level: setting to No JavaScript. - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 11 - options: - 11: created - 8: seeders - 4: size - 3: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 50 on your account preferences. - -login: - path: login/ - method: post - inputs: - loginform: show - page_history: disable - page_js_enabled: false - redirect: "{{ .Config.sitelink }}" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - keeploggedin: on - restricttoip: "" - ok: Login - error: - - selector: div:contains("do not match") - test: - path: / - selector: a[href="/logout/"] - -search: - paths: - # https://themixingbowl.org/torrent/advancedsearch/?search=tmb&go=Search&freesearch=on&results-order1=11.d&results-page=1 - - path: torrent/advancedsearch/ - inputs: - search: "{{ .Keywords }}" - go: Search - artistsearch: "{{ .Query.Artist }}" - freesearch: "{{ if .Config.freeleech }}on{{ else }}{{ end }}" - results-order1: "{{ .Config.sort }}.{{ .Config.type }}" - results-page: 1 - # can search by genre but need join support. &tagsearch=acid,+house for Acid and House - - rows: - selector: table.torrenttable > tbody > tr:has(a[href^="/torrent/download/"]) - - fields: - category: - text: 1 - title: - selector: td.name a - details: - selector: td:nth-child(16) a - attribute: href - filters: - - name: append - args: "/details/" - download: - selector: a[href^="/torrent/download/"] - attribute: href - size: - selector: td:nth-child(5) - artist: - selector: span.artist - optional: true - genre: - selector: span.tag - optional: true - _codec: - selector: td:nth-child(6) - optional: true - _bit: - selector: td:nth-child(7) - optional: true - description: - text: "Artist=[{{ .Result.artist }}] , Tags={{ .Result.genre }} , Codec=[{{ .Result._codec }}] , BitRate=[{{ .Result._bit }}]" - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - date: - selector: td:nth-child(12) - # auto adjusted by site account profile - filters: - - name: replace - args: ["at", ""] - - name: dateparse - args: "d MMM yyyy HH:mm" - downloadvolumefactor: - case: - "img[src$=\"freeleech.png\"]": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thenewfun.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thenewfun.yml deleted file mode 100644 index 4881942f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thenewfun.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: thenewfun -name: The-New-Fun -description: "The-New-Fun is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://the-new-fun.com/ -legacylinks: - - https://the-new-fun.spdns.eu/ - -caps: - categorymappings: - - {id: 119, cat: Other, desc: "Bilder"} - - {id: 139, cat: TV/Documentary, desc: "Doku - HD"} - - {id: 141, cat: TV/Documentary, desc: "Doku - Pack"} - - {id: 140, cat: TV/Documentary, desc: "Doku - SD"} - - {id: 142, cat: TV/Documentary, desc: "Doku - Sonstige"} - - {id: 120, cat: Books, desc: "Ebook"} - - {id: 150, cat: Movies/UHD, desc: "Filme - 2160p"} - - {id: 131, cat: TV/Anime, desc: "Filme - Animie"} - - {id: 101, cat: Movies/BluRay, desc: "Filme - Blue Ray"} - - {id: 102, cat: Movies/Other, desc: "Filme - Bollywood"} - - {id: 149, cat: Movies/DVD, desc: "Filme - DVD"} - - {id: 104, cat: Movies/DVD, desc: "Filme - DVD-R"} - - {id: 135, cat: Movies/HD, desc: "Filme - HD"} - - {id: 130, cat: Movies, desc: "Filme - Klassiker"} - - {id: 147, cat: Movies, desc: "Filme - Pack"} - - {id: 134, cat: Movies/SD, desc: "Filme - SD"} - - {id: 136, cat: Movies/UHD, desc: "Filme - UHD"} - - {id: 105, cat: Movies/HD, desc: "Filme - x264/x265"} - - {id: 106, cat: Movies/SD, desc: "Filme - XviD / DivX"} - - {id: 127, cat: Movies, desc: "Für Unsere kleinsten"} - - {id: 121, cat: PC/Mobile-Other, desc: "Handy Stuff"} - - {id: 124, cat: Audio, desc: "Musik - Alben"} - - {id: 123, cat: Audio, desc: "Musik - Discography"} - - {id: 122, cat: Audio/Audiobook, desc: "Musik - Hörbuch"} - - {id: 133, cat: Audio/MP3, desc: "Musik - MP3/Mp4"} - - {id: 146, cat: Audio, desc: "Musik - Pack"} - - {id: 125, cat: Audio/Video, desc: "Musik - Video"} - - {id: 113, cat: PC, desc: "Programme - Linux"} - - {id: 114, cat: PC/Mac, desc: "Programme - Mac"} - - {id: 115, cat: PC/0day, desc: "Programme - Windows"} - - {id: 137, cat: TV/HD, desc: "Serien - HD"} - - {id: 116, cat: TV, desc: "Serien - Pack"} - - {id: 138, cat: TV/SD, desc: "Serien - SD"} - - {id: 143, cat: TV/Other, desc: "Serien - Sonstige"} - - {id: 144, cat: TV/UHD, desc: "Serien - UHD"} - - {id: 129, cat: Other, desc: "Sonstiges"} - - {id: 109, cat: PC/Mobile-Other, desc: "Spiele - Handy"} - - {id: 112, cat: Console, desc: "Spiele - Konsolen"} - - {id: 111, cat: PC/Games, desc: "Spiele - Mac / Linux"} - - {id: 110, cat: PC/Games, desc: "Spiele - PC"} - - {id: 118, cat: TV/Sport, desc: "WWE - Sport"} - - {id: 69, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: onlyupload - type: checkbox - label: Filter OnlyUpload only - default: false - - name: info_free - type: info - label: About OnlyUpload at T-N-F - default: "
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Prowlarr search results page these are tagged as Freeleech).
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents pro Seite: setting to 100 on your UserCP page. The default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts are automatically deleted after 42 days and leechers are also kicked." - - name: info_seedhours - type: info - label: Seeding Hours - default: Before using this indexer, make sure that you have set Meine Seedzeit on your UserCP page. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.tabletitle:contains("fehlgeschlagen") - test: - path: / - selector: a[href="logout.php"] - -search: - # https://the-new-fun.spdns.eu/browse.php?showsearch=1&c140=1&c142=1&search=2022+1080p&incldead=1&orderby=added&sort=desc - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - showsearch: 1 - search: "{{ .Keywords }}" - # 0 active, 1 all, 2 dead - incldead: 1 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # site does not support imdbid search from the browse.php page or return imdb links in results - - rows: - selector: "table.tableinborder[cellspacing=\"1\"][cellpadding=\"0\"]:not(:has(a[href=\"index.php\"])) > tbody > tr:has(a[href^=\"download.php\"]){{ if .Config.onlyupload }}:has(img[src=\"pic/oupic.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php"] - details: - selector: a[href^="details.php"] - attribute: href - download: - selector: a[href^="download.php"] - attribute: href - poster: - selector: img[id="img-1"] - attribute: src - size: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(1) b:nth-child(1) - files: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(1) b:nth-child(2) - seeders: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(2) b:nth-child(1) - leechers: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(2) > b:nth-of-type(2) - grabs: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(3) b:nth-child(1) - date: - selector: td.tablea table tbody tr:nth-child(2) td:nth-child(5) - filters: - - name: append - args: " +01:00" # CET - - name: replace - args: ["\xA0", " "] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/oupic.gif"]: 0 # only upload is counted - "*": 1 - uploadvolumefactor: - case: - img[src="pic/freeleech.gif"]: 0 # nothing is counted - "*": 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# NetVision diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theoccult.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theoccult.yml deleted file mode 100644 index 11054ad0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theoccult.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -id: theoccult -name: The Occult -description: "TheOccult (TO) is a Private Torrent Tracker for CULT / MYTHOLOGY E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://theoccult.click/ -legacylinks: - - http://theoccult.click/ - -caps: - categorymappings: - - {id: 63, cat: Other, desc: "Academic / Reference"} - - {id: 21, cat: Other, desc: "Everything Else"} - - {id: 68, cat: Other, desc: "Mythology and Folklore"} - - {id: 61, cat: Other, desc: "Rajneesh"} - - {id: 1, cat: Other, desc: "Thelema / Crowley"} - - {id: 51, cat: Other, desc: "African/African Diaspora Relig"} - - {id: 30, cat: Other, desc: "Fourth Way"} - - {id: 17, cat: Other, desc: "Native American"} - - {id: 57, cat: Other, desc: "Ritual Magic"} - - {id: 74, cat: Other, desc: "Theosophy / Anthroposophy"} - - {id: 10, cat: Other, desc: "Alchemy"} - - {id: 7, cat: Other, desc: "Freemasonry"} - - {id: 54, cat: Other, desc: "New Age"} - - {id: 34, cat: Other, desc: "Rosicrucian"} - - {id: 73, cat: Other, desc: "Traditional Left-Hand Path"} - - {id: 69, cat: Other, desc: "Ancient Languages"} - - {id: 6, cat: Other, desc: "Golden Dawn"} - - {id: 76, cat: Other, desc: "Non-Dualism"} - - {id: 75, cat: Other, desc: "Sacred Geometry"} - - {id: 71, cat: Other, desc: "Traditional Witchcraft"} - - {id: 9, cat: Other, desc: "Astrology"} - - {id: 35, cat: Other, desc: "Grimoires"} - - {id: 52, cat: Other, desc: "Northern European Paganism"} - - {id: 31, cat: Other, desc: "Satanism"} - - {id: 77, cat: Other, desc: "Transpersonal Psychology"} - - {id: 25, cat: Other, desc: "Buddhism "} - - {id: 26, cat: Other, desc: "Hinduism"} - - {id: 70, cat: Other, desc: "Other Divination"} - - {id: 28, cat: Other, desc: "Shamanism"} - - {id: 19, cat: Other, desc: "Wicca / NeoWicca / Eclectic"} - - {id: 32, cat: Other, desc: "Chaos Magic "} - - {id: 64, cat: Other, desc: "Lovecraft Mythos"} - - {id: 67, cat: Other, desc: "Other Eastern Traditions"} - - {id: 56, cat: Other, desc: "Southern European Paganism"} - - {id: 20, cat: Other, desc: "Yoga / Tantra"} - - {id: 66, cat: Other, desc: "Energy Healing"} - - {id: 13, cat: Other, desc: "Lucid Dreams/Astral Projection"} - - {id: 49, cat: Other, desc: "Paranormal"} - - {id: 72, cat: Other, desc: "Specialty Presses"} - - {id: 4, cat: Other, desc: "Enochian"} - - {id: 14, cat: Other, desc: "Meditation"} - - {id: 33, cat: Other, desc: "Parapsychology"} - - {id: 58, cat: Other, desc: "Sufism"} - - {id: 11, cat: Other, desc: "Entheogens"} - - {id: 53, cat: Other, desc: "Middle Eastern Magic"} - - {id: 15, cat: Other, desc: "Philosophy"} - - {id: 55, cat: Other, desc: "Taoism / Daoism"} - - {id: 29, cat: Other, desc: "Esoteric Christianity"} - - {id: 65, cat: Other, desc: "Modern Grimoires"} - - {id: 12, cat: Other, desc: "Qabalah / Kabbalah / Cabala"} - - {id: 18, cat: Other, desc: "Tarot / Oracle Cards"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from the TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.98 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theoldschool-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theoldschool-api.yml deleted file mode 100644 index 18a99c5c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theoldschool-api.yml +++ /dev/null @@ -1,226 +0,0 @@ ---- -id: theoldschool-api -name: The Old School (API) -description: "The Old School (TOS) is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://theoldschool.cc/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Films"} - - {id: 6, cat: Movies, desc: "Films VOSTFR"} - - {id: 2, cat: TV, desc: "Series"} - - {id: 7, cat: TV, desc: "Series VOSTFR"} - - {id: 8, cat: TV, desc: "Series Pack"} - - {id: 9, cat: TV, desc: "Series Pack VOSTFR"} - - {id: 3, cat: Books, desc: "Ebooks"} - - {id: 4, cat: Console, desc: "Jeux"} - - {id: 5, cat: PC, desc: "Logiciel"} - - {id: 10, cat: TV/Sport, desc: "Sports"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your The Old School account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: tmdbidonly - type: checkbox - label: Disable IMDB and TVDB ID search (only support TMDB ID) to potentially improve Sonarr and Radarr results - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: vfq - type: checkbox - label: Replace VFQ with FRENCH - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "'Pruning' account
  • Your membership requires that you remain active at TOS.
  • To not be disabled, you must log in to the tracker at least once every 90 days or continuously see at least 1 torrent. If you haven't logged in for more than 90 days and you don't have any seed torrents as of midnight UTC, your account will be deactivated.
  • To be reactivated, you also have 30 days to log in and show that your account is back to normal.
  • Disabled account: 90 days of inactivity and no seed torrent.
  • 'Pruned' account: Has been deactivated for 30 days , and 90 days of inactivity.
" - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - imdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.IMDBIDShort }}{{ end }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ if .Config.tmdbidonly }}{{ else }}{{ .Query.TVDBID }}{{ end }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_phase1: - selector: name - title_vfq: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(VFQ)\\b", "FRENCH"] - title_phase2: - text: "{{ if .Config.vfq }}{{ .Result.title_vfq }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_vostfr: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase3: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase3 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase3 }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)^None$", ""] - - name: replace - args: [" & ", "_&_"] - description: - text: "{{ .Result.genre }}" - files: - selector: num_file - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - downloadvolumefactor: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - uploadvolumefactor: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double -# global MR is 0.6 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.6 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D custom edition 1.1.0 (based on 7.2.5) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theparadiese.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theparadiese.yml deleted file mode 100644 index f28c25fd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theparadiese.yml +++ /dev/null @@ -1,215 +0,0 @@ ---- -id: theparadiese -name: The Paradiese -description: "The Paradiese is a GERMAN Private tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://theparadiese.hopto.org/ -legacylinks: - - https://theparadiese.org/ - -caps: - categorymappings: - - {id: 125, cat: Movies, desc: "Movie Anime"} - - {id: 134, cat: Movies/UHD, desc: "Movie Pack/4K/UHD"} - - {id: 118, cat: Movies/HD, desc: "Movie Pack HD"} - - {id: 132, cat: Movies/SD, desc: "Movie Pack SD"} - - {id: 133, cat: Movies/UHD, desc: "Movie 4K/UHD"} - - {id: 32, cat: Movies/HD, desc: "Movie HD"} - - {id: 28, cat: Movies/SD, desc: "Movie SD"} - - {id: 26, cat: Movies/3D, desc: "Movie 3D"} - - {id: 136, cat: Movies/DVD, desc: "Movie DVD"} - - {id: 121, cat: TV/UHD, desc: "Serien Pack/4K/UHD"} - - {id: 119, cat: TV/HD, desc: "Serien Pack HD"} - - {id: 59, cat: TV/SD, desc: "Serien Pack SD"} - - {id: 122, cat: TV/UHD, desc: "Serien 4K/UHD"} - - {id: 57, cat: TV/HD, desc: "Serien HD"} - - {id: 60, cat: TV/SD, desc: "Serien SD"} - - {id: 126, cat: TV/Anime, desc: "Serien Anime"} - - {id: 145, cat: Audio, desc: "Music Soundtrack"} - - {id: 52, cat: Audio, desc: "Music Pack"} - - {id: 48, cat: Audio, desc: "Music Album"} - - {id: 49, cat: Audio, desc: "Music Charts"} - - {id: 54, cat: Audio, desc: "Music Single"} - - {id: 110, cat: Audio/MP3, desc: "Music Mp3"} - - {id: 120, cat: Audio/Lossless, desc: "Music Flac"} - - {id: 123, cat: Audio/Audiobook, desc: "Music Hörbücher"} - - {id: 109, cat: Audio/Video, desc: "Music Video"} - - {id: 147, cat: Other, desc: "Kids 4K/UHD"} - - {id: 140, cat: Other, desc: "Kids Ani /Trick"} - - {id: 143, cat: Other, desc: "Kids Pack HD"} - - {id: 142, cat: Other, desc: "Kids HD"} - - {id: 144, cat: Other, desc: "Kids Pack SD"} - - {id: 141, cat: Other, desc: "Kids SD"} - - {id: 96, cat: Books/EBook, desc: "Sonstiges E-Book"} - - {id: 69, cat: Other, desc: "Sonstiges Tutorial"} - - {id: 71, cat: Other/Misc, desc: "Sonstiges Other"} - - {id: 139, cat: XXX, desc: "XXX Anime"} - - {id: 75, cat: XXX, desc: "XXX Pack"} - - {id: 73, cat: XXX, desc: "XXX HD"} - - {id: 76, cat: XXX, desc: "XXX SD"} - - {id: 74, cat: XXX, desc: "XXX Other"} - - {id: 127, cat: TV/Documentary, desc: "Doku Pack HD"} - - {id: 128, cat: TV/Documentary, desc: "Doku Pack SD"} - - {id: 14, cat: TV/Documentary, desc: "Doku HD"} - - {id: 15, cat: TV/Documentary, desc: "Doku SD"} - - {id: 12, cat: PC/0day, desc: "App Win"} - - {id: 10, cat: PC/Mac, desc: "App Mac/Linux"} - - {id: 11, cat: PC/Mobile-Other, desc: "App Handy"} - - {id: 17, cat: PC/Games, desc: "Game PC"} - - {id: 129, cat: Console, desc: "Game Konsole"} - - {id: 131, cat: PC/Mobile-Other, desc: "Game Handy"} - - {id: 117, cat: TV/Sport, desc: "Sport SD"} - - {id: 137, cat: TV/Sport, desc: "Sport HD"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: info_free - type: info - label: About OnlyUpload at The Paradiese - default: "
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Jackett dashboard search results these are tagged as Freeleech).
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents pro Seite: setting to 60 on your Control Panel. The default is 15. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts are automatically deleted after 42 days." - -login: - path: login.php - method: form - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - pin: "{{ .Config.pin }}" - error: - - selector: div#login_error - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: selection.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 name, 1 descr, 2 both - blah: 0 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # site does not support imdbid searching or display imdb links in results. - - rows: - selector: div.selection_wrap - - fields: - category_p1: - selector: div.kat_cat_pic_name - category_p2: - selector: div.kat_cat_pic_name_b - categorydesc: - text: "{{ .Result.category_p1 }} {{ .Result.category_p2 }}" - title: - selector: a.selection_a - filters: - - name: re_replace - args: ["^\\[[\\w ]*\\]\\s?", ""] - details: - selector: a.selection_a - attribute: href - _id: - selector: a.selection_a - attribute: href - filters: - - name: querystring - args: id - download: - text: "download.php?torrent={{ .Result._id }}" - poster: - selector: div[id^="details"] img - attribute: src - size: - selector: div.selection_unter_ad - grabs: - selector: div.selection_unter_ae - date_day: - # Heute 13:30:04 - # Gestern 09:10:10 - selector: div.selection_unter_ab:not(:contains(".")) - optional: true - filters: - - name: replace - args: ["Heute", "Today"] - - name: replace - args: ["Gestern", "Yesterday"] - - name: append - args: " +01:00" # CET - date_year: - # 30.02.2018 um 23:12:50 - selector: div.selection_unter_ab:contains(".") - optional: true - filters: - - name: replace - args: [" um", ""] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - seeders: - selector: div.selection_unter_aa - leechers: - selector: div.selection_unter_aaa - downloadvolumefactor: - case: - ":root:has(div.onlyup)": 0 # GLobal Freeleech event - "*": 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - case: - # 2.5 days (as seconds = 2.5 x 24 x 60 x 60) - ":root:has(div.onlyup)": 216000 # GLobal Freeleech event - # 2 days (as seconds = 2 x 24 x 60 x 60) - "*": 172800 - minimumratio: - text: 0.8 -# 3xT diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thepiratebay.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thepiratebay.yml deleted file mode 100644 index d9ea8d49..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thepiratebay.yml +++ /dev/null @@ -1,216 +0,0 @@ ---- -id: thepiratebay -name: The Pirate Bay -description: "The Pirate Bay (TPB) is the galaxy’s most resilient Public BitTorrent site" -language: en-US -type: public -encoding: UTF-8 -links: - - https://thepiratebay.org/ - - https://thepiratebay.unblockninja.com/ - - https://thepiratebay.ninjaproxy1.com/ - - https://tpb.proxyninja.org/ - - https://thepiratebay.proxyninja.net/ - - https://thepiratebay.torrentbay.st/ - - https://tpb.skynetcloud.site/ - - https://piratehaven.xyz/ - - https://mirrorbay.top/ - - https://thepiratebay0.org/ - - https://thepiratebay10.xyz/ - - https://pirateproxylive.org/ - - https://thehiddenbay.com/ - - https://thepiratebay.zone/ - - https://tpb.party/ - - https://piratebayproxy.live/ - - https://piratebay.live/ - - https://piratebay.party/ - - https://thepiratebay.party/ - - https://thepiratebaye.org/ - - https://thepiratebay.cloud/ - - https://tpb-proxy.xyz/ - - https://tpb.re/ - - https://tpirbay.site/ - - https://tpirbay.top/ - - https://tpirbay.xyz/ -legacylinks: - - https://pirate-proxy.page/ - - https://5mins.shop/ - - https://tpb.surf/ - - https://tpb.monster/ - - https://thepiratebay.host/ - - https://piratetoday.xyz/ - - https://tpb.wtf/ - - https://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.onion.ly/ - - https://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.tor2web.to/ - - https://piratebayo3klnzokct3wt5yyxb2vpebbuyjl7m623iaxmqhsd52coid.tor2web.link/ - - https://tpb25.ukpass.co/ - - https://tpb29.ukpass.co/ - - https://piratenow.xyz/ - - https://pirate-proxy.ink/ - - https://proxifiedpiratebay.org/ - - https://unlockedpiratebay.com/ - - https://tpb.one/ - - https://piratebayorg.net/ - - https://tpbproxy.click/ - - https://pirateproxy.live/ - - https://ukpiratebay.org/ - - https://piratebay.by/ - - https://pirate-proxy.date/ - - https://thepirateproxy.net/ - - https://thepiratebay.abcproxy.org/ - - https://tpb.proxyninja.net/ - - https://tpb31.ukpass.co/ - - https://thepiratebay10.org/ - - https://pirate-proxy.africa/ - - https://5mins.eu/ - - https://piratebay.army/ - - https://tpb-visit.me/ - - https://pirate-proxy.ong/ - -caps: - categorymappings: - # Audio - - {id: 100, cat: Audio, desc: "Audio"} - - {id: 101, cat: Audio, desc: "Music"} - - {id: 102, cat: Audio/Audiobook, desc: "Audio Books"} - - {id: 103, cat: Audio, desc: "Sound Clips"} - - {id: 104, cat: Audio/Lossless, desc: "FLAC"} - - {id: 199, cat: Audio/Other, desc: "Audio Other"} - # Video - - {id: 200, cat: Movies, desc: "Video"} - - {id: 201, cat: Movies, desc: "Movies"} - - {id: 202, cat: Movies, desc: "Movies DVDR"} - - {id: 203, cat: Audio/Video, desc: "Music Videos"} - - {id: 204, cat: Movies/Other, desc: "Movie Clips"} - - {id: 205, cat: TV, desc: "TV Shows"} - - {id: 206, cat: TV/Other, desc: "Handheld"} - - {id: 207, cat: Movies/HD, desc: "HD - Movies"} - - {id: 208, cat: TV/HD, desc: "HD - TV shows"} - - {id: 209, cat: Movies/3D, desc: "3D"} - - {id: 210, cat: Movies/SD, desc: "CAM/TS"} - - {id: 211, cat: Movies/UHD, desc: "UHD/4k - Movies"} - - {id: 212, cat: TV/UHD, desc: "UHD/4k - TV shows"} - - {id: 299, cat: Movies/Other, desc: "Video Other"} - # Applications - - {id: 300, cat: PC, desc: "Applications"} - - {id: 301, cat: PC, desc: "Windows"} - - {id: 302, cat: PC/Mac, desc: "Mac"} - - {id: 303, cat: PC, desc: "UNIX"} - - {id: 304, cat: PC/Mobile-Other, desc: "Handheld"} - - {id: 305, cat: PC/Mobile-iOS, desc: "IOS (iPad/iPhone)"} - - {id: 306, cat: PC/Mobile-Android, desc: "Android"} - - {id: 399, cat: PC, desc: "Other OS"} - # Games - - {id: 400, cat: Console, desc: "Games"} - - {id: 401, cat: PC/Games, desc: "PC"} - - {id: 402, cat: PC/Mac, desc: "Mac"} - - {id: 403, cat: Console/PS4, desc: "PSx"} - - {id: 404, cat: Console/XBox, desc: "XBOX360"} - - {id: 405, cat: Console/Wii, desc: "Wii"} - - {id: 406, cat: Console/Other, desc: "Handheld"} - - {id: 407, cat: Console/Other, desc: "IOS (iPad/iPhone)"} - - {id: 408, cat: Console/Other, desc: "Android"} - - {id: 499, cat: Console/Other, desc: "Games Other"} - # Porn - - {id: 500, cat: XXX, desc: "Porn"} - - {id: 501, cat: XXX, desc: "Movies"} - - {id: 502, cat: XXX/DVD, desc: "Movies DVDR"} - - {id: 503, cat: XXX/ImageSet, desc: "Pictures"} - - {id: 504, cat: XXX, desc: "Games"} - - {id: 505, cat: XXX/x264, desc: "HD - Movies"} - - {id: 506, cat: XXX, desc: "Movie Clips"} - - {id: 507, cat: XXX/UHD, desc: "UHD/4k - Movies"} - - {id: 599, cat: XXX/Other, desc: "Porn other"} - # Other - - {id: 600, cat: Other, desc: "Other"} - - {id: 601, cat: Books/EBook, desc: "E-books"} - - {id: 602, cat: Books/Comics, desc: "Comics"} - - {id: 603, cat: Books, desc: "Pictures"} - - {id: 604, cat: Books, desc: "Covers"} - - {id: 605, cat: Books, desc: "Physibles"} - - {id: 699, cat: Books/Other, desc: "Other Other"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: apiurl - label: API URL - type: text - default: apibay.org - - name: uploader - type: text - label: Filter by Uploader - - name: info_uploader - type: info - label: About filtering by Uploader - default: "You can filter by Uploader by entering a Case Sensitive username, or leave empty to get all results.
Note: this is the username of the Uploader and not the Groupname that often show up at the end of TPB titles, eg -MeGusta." - -search: - paths: - - path: "https://{{ .Config.apiurl }}/{{ if .Keywords }}q.php?q={{ .Keywords }}&cat={{ join .Categories \",\" }}{{ else }}precompiled/data_top100_recent.json{{ end }}" - response: - type: json - - keywordsfilters: - # remove it's #8829 - - name: re_replace - args: ["(?i)\\bit's\\b", ""] - # replace simplified Chinese as this confuses TPB search engine #7291 - - name: re_replace - args: ["([\\p{IsCJKUnifiedIdeographs}\\W]+)", "."] - - name: tolower - - rows: - selector: "${{ if .Config.uploader }}:has(username:contains({{ .Config.uploader }})){{ else }}{{ end }}" - count: - selector: $[0].id - - fields: - _id: - selector: id - category: - selector: category - title: - selector: name - filters: - - name: re_replace - args: ["- (\\w+-?\\w*)$", "-$1"] - - name: re_replace # Season X / Season X Complete --> S0X - args: ["(?i)\\bSeason[\\s\\.]+(\\d)([\\s\\.]+Complete)?\\b", "S0$1"] - - name: re_replace # Season XX / Season XX Complete --> SXX - args: ["(?i)\\bSeason[\\s\\.]+(\\d{1,2})([\\s\\.]+Complete)?\\b", "S$1"] - details: - text: "{{ .Config.sitelink }}description.php?id={{ .Result._id }}" - infohash: - selector: info_hash - imdbid: - selector: imdb - date: - # unix - selector: added - size: - selector: size - files: - selector: num_files - optional: true - seeders: - selector: seeders - leechers: - selector: leechers - _username: - selector: username - description: - selector: name - filters: - - name: prepend - args: "Uploader: {{ .Result._username }}
" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theplace.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theplace.yml deleted file mode 100644 index bf835edf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theplace.yml +++ /dev/null @@ -1,145 +0,0 @@ ---- -id: theplace -name: The Place -description: "ThePlace (TP) is a Private Torrent Tracker for SEDUCTION / SELF-IMPROVEMENT E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -certificates: - - 3e8e14f055ed071a11e7ea64e96e3c4bf058f2f9 # Expired 27 Feb 2026 -links: - - https://theplace.click/ -legacylinks: - - http://theplace.click/ - -caps: - categorymappings: - - {id: 41, cat: Other, desc: "Dance / Singing / Voice"} - - {id: 64, cat: Other, desc: "Fighting / Martial Arts"} - - {id: 43, cat: Other, desc: "Hypnosis / NLP"} - - {id: 201, cat: Other, desc: "Migrated"} - - {id: 39, cat: Other, desc: "Seduction"} - - {id: 57, cat: Other, desc: "Everything Else"} - - {id: 42, cat: Other, desc: "Food / Drink / Cooking"} - - {id: 44, cat: Other, desc: "Languages / Accents"} - - {id: 200, cat: Other, desc: "Migration"} - - {id: 40, cat: Other, desc: "Sex"} - - {id: 58, cat: Other, desc: "Fashion / Clothing / Grooming"} - - {id: 47, cat: Other, desc: "Health / Fitness / Massage"} - - {id: 45, cat: Other, desc: "Magic / Illusions / Tricks"} - - {id: 59, cat: Other, desc: "Psychology / Body Language"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from the TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - # https://thegeeks.click/browse.php?sort=added&h=6&d=DESC - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.98 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theshow.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theshow.yml deleted file mode 100644 index 9f3da48c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/theshow.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: theshow -name: The Show -description: "TheShow (TSBZ) is a Private Torrent Tracker for ENTERTAINMENT-INDUSTRY E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -certificates: - - 3bf77e8aa3cea0c26ae0bceb5c1f20fe75a3d64c # Expired 21st Feb 2026 -links: - - https://theshow.click/ -legacylinks: - - http://theshow.click/ - -caps: - categorymappings: - - {id: 72, cat: Other, desc: "-empty-"} - - {id: 81, cat: Other, desc: "-empty-"} - - {id: 17, cat: Other, desc: "A-V: Film/Video Editing"} - - {id: 30, cat: Other, desc: "A-V: Sound Recording/Design"} - - {id: 6, cat: Other, desc: "A-V: Videography"} - - {id: 9, cat: Other, desc: "A-V: Visual FX"} - - {id: 29, cat: Other, desc: "Business / Distribution"} - - {id: 56, cat: Other, desc: "Creative Writing"} - - {id: 25, cat: Other, desc: "Digital Craft: 3D Modeling"} - - {id: 63, cat: Other, desc: "Everything Else"} - - {id: 65, cat: Other, desc: "Film History / Theory "} - - {id: 64, cat: Other, desc: "Graphics / Photo Editing"} - - {id: 32, cat: Other, desc: "Magazines / Trade Journals"} - - {id: 66, cat: Other, desc: "Mindset / Creativity"} - - {id: 67, cat: Other, desc: "Music Business / Promotion"} - - {id: 73, cat: Other, desc: "Music: Shows/Special Events"} - - {id: 200, cat: Other, desc: "NEW A-V: Videography"} - - {id: 70, cat: Other, desc: "Performances: Competitions"} - - {id: 94, cat: Other, desc: "Performances: Dance/Ballet"} - - {id: 82, cat: Other, desc: "Performances: Drama/Art"} - - {id: 76, cat: Other, desc: "Performances: Sketch/Improv"} - - {id: 75, cat: Other, desc: "Performances: Spoken Word"} - - {id: 78, cat: Other, desc: "Performances: Standup Comedy"} - - {id: 51, cat: Other, desc: "Performing Craft: Acting"} - - {id: 4, cat: Other, desc: "Performing Craft: Dance"} - - {id: 10, cat: Other, desc: "Production: Cinematography"} - - {id: 1, cat: Other, desc: "Production: Directing (Film)"} - - {id: 7, cat: Other, desc: "Production: Directing(Theatre)"} - - {id: 53, cat: Other, desc: "Production: Visuals"} - - {id: 77, cat: Other, desc: "Radio: Comedy/Drama/Mystery"} - - {id: 79, cat: Other, desc: "Radio: Entertain/Arts/Narrativ"} - - {id: 80, cat: Other, desc: "Radio: Music Performances"} - - {id: 83, cat: Other, desc: "Scripts: Radio"} - - {id: 84, cat: Other, desc: "Scripts: Television"} - - {id: 85, cat: Other, desc: "Scripts: Theatre"} - - {id: 49, cat: Other, desc: "Software / Tutorials"} - - {id: 26, cat: Other, desc: "Stock: Film / Video"} - - {id: 54, cat: Other, desc: "Stock: Music / Sound FX"} - - {id: 13, cat: Other, desc: "Stock: Photos / Illustrations"} - - {id: 69, cat: Other, desc: "TV Shows: Action/Drama/Mystery"} - - {id: 68, cat: Other, desc: "TV Shows: Comedy"} - - {id: 71, cat: Other, desc: "TV Shows: Entertain/Chat/Goss"} - - {id: 92, cat: Other, desc: "Visual Craft: Design Theory"} - - {id: 91, cat: Other, desc: "Visual Craft: Drawing/Drafting"} - - {id: 87, cat: Other, desc: "Visual Craft: Fashion/Makeup"} - - {id: 90, cat: Other, desc: "Visual Craft: Handcraft/Sculpt"} - - {id: 93, cat: Other, desc: "Visual Craft: Jewelry Making"} - - {id: 11, cat: Other, desc: "Visual Craft: Needlework"} - - {id: 52, cat: Other, desc: "Visual Craft: Photography"} - - {id: 89, cat: Other, desc: "Visual Craft: Scrapbooking"} - - {id: 88, cat: Other, desc: "Visual Craft: Tattoos/Body Art"} - - {id: 21, cat: Other, desc: "Writing Craft: Screenwriting"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.98 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thevault.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thevault.yml deleted file mode 100644 index dcda18aa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/thevault.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: thevault -name: The Vault -description: "TheVault (TVBZ) is a Private Torrent Tracker for BUSINESS / MARKETING E-LEARNING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://thevault.click/ -legacylinks: - - http://thevault.click/ - -caps: - categorymappings: - - {id: 200, cat: Other, desc: "ABCs / Basics"} - - {id: 202, cat: Other, desc: "Artificial Intelligence"} - - {id: 107, cat: Other, desc: "Author: Dan Kennedy"} - - {id: 110, cat: Other, desc: "Author: Dan Pena"} - - {id: 117, cat: Other, desc: "Author: Eben Pagan"} - - {id: 92, cat: Other, desc: "Author: Jay Abraham"} - - {id: 96, cat: Other, desc: "Author: John Reese"} - - {id: 63, cat: Other, desc: "Author: Robert Kiyosaki"} - - {id: 106, cat: Other, desc: "Autobiographies / Biographies"} - - {id: 118, cat: Other, desc: "Ayn Rand / Objectivism"} - - {id: 112, cat: Other, desc: "Consulting"} - - {id: 94, cat: Other, desc: "Copywriting"} - - {id: 103, cat: Other, desc: "Corporate Design / Branding"} - - {id: 84, cat: Other, desc: "Customer Service"} - - {id: 65, cat: Other, desc: "Economics / Finance"} - - {id: 113, cat: Other, desc: "Entertainment Business"} - - {id: 73, cat: Other, desc: "Hypnosis / NLP"} - - {id: 67, cat: Other, desc: "Investing / Trading / Stocks"} - - {id: 75, cat: Other, desc: "Magazines"} - - {id: 70, cat: Other, desc: "Management / Leadership"} - - {id: 85, cat: Other, desc: "Mindset / Achievement"} - - {id: 80, cat: Other, desc: "Misc: Audio"} - - {id: 90, cat: Other, desc: "Misc: Other"} - - {id: 83, cat: Other, desc: "Misc: Software"} - - {id: 81, cat: Other, desc: "Misc: Video"} - - {id: 82, cat: Other, desc: "Misc: Written / E-Books"} - - {id: 62, cat: Other, desc: "Money Management / Taxes"} - - {id: 93, cat: Other, desc: "Negotiation / Contracts"} - - {id: 97, cat: Other, desc: "Net: Affiliate Schemes"} - - {id: 105, cat: Other, desc: "Net: Blogging"} - - {id: 76, cat: Other, desc: "Net: E-Commerce"} - - {id: 100, cat: Other, desc: "Net: eBay / Online Auctions"} - - {id: 115, cat: Other, desc: "Net: Email Marketing"} - - {id: 95, cat: Other, desc: "Net: Marketing"} - - {id: 101, cat: Other, desc: "Net: Misc"} - - {id: 98, cat: Other, desc: "Net: Pay-Per-Click Advertising"} - - {id: 108, cat: Other, desc: "Net: Search Engine Optimizing"} - - {id: 99, cat: Other, desc: "Net: Traffic Generation"} - - {id: 102, cat: Other, desc: "Net: Web Site/Blog Templates"} - - {id: 114, cat: Other, desc: "Network Marketing (MLM)"} - - {id: 111, cat: Other, desc: "Offshore / Tax Avoidance"} - - {id: 116, cat: Other, desc: "Private Label Rights (PLR)"} - - {id: 68, cat: Other, desc: "Real Estate / Property Develop"} - - {id: 104, cat: Other, desc: "Recruitment / Interviewing"} - - {id: 71, cat: Other, desc: "Sales / Marketing"} - - {id: 72, cat: Other, desc: "Small Business / Entrepreneurs"} - - {id: 109, cat: Other, desc: "Sports / Gambling"} - - {id: 91, cat: Other, desc: "TTC Lectures"} - - modes: - search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_tcg - type: info - label: Message from the TCG staff - default: We wish to encourage all members to participate in the communities we are creating. We have worked with the Jackett maintainers to ensure that the security and longevity of our trackers continues, and as such some torrents are removed from the results in Jackett. - - name: info_activity - type: info - label: Account Inactivity - default: "Unparked accounts that haven't visited (read: browsed) the site for 38 days (and are over 28 days old) are pruned. Parked accounts are pruned when 112 days have elapsed since the last recorded activity (read: browsed). Seeding activity does NOT count, you must BROWSE the site. Donators with a star (and ONLY donators with a star) are immune to auto-pruning. Power Users and above are also immune. We regularly clean out accounts with very low share ratios that are subject to the Ratio Watch system." - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: main.php - selector: a[href^="logout.php?id="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}filter_cat[{{.}}]=1&{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 incldead, 2 onlydead, 3 free (not used by request), 4 exclusive, 5 neutral - incldead: 1 - titleonly: 1 - # 0 Exact, 1 Fuzzy, 3 Parsed, 4 Simple # 2 Pure is not used - nonboolean: 0 - sort: "{{ .Config.sort }}" - d: "{{ .Config.type }}" - - rows: - selector: table[border="0"] > tbody > tr.ttable:has(a[href^="browse.php?cat="]):not(:has(font[color="blue"])):not(:contains("*TCG*")) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - filters: - - name: replace - args: ["Never", "0"] - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - downloadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - uploadvolumefactor: - case: - "font[color=\"green\"]": 0 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# XBTT TCG 0.98 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tjupt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tjupt.yml deleted file mode 100644 index c046dff2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tjupt.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: tjupt -name: TJUPT (北洋园PT) -description: "TJUPT (北洋园PT) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://tjupt.org/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies电影"} - - {id: 402, cat: TV, desc: "TV Series剧集"} - - {id: 403, cat: TV, desc: "TV Shows综艺"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries资料"} - - {id: 405, cat: TV/Anime, desc: "Animations动漫"} - - {id: 406, cat: Audio/Video, desc: "Music Videos音乐"} - - {id: 407, cat: TV/Sport, desc: "Sports体育"} - - {id: 408, cat: PC, desc: "Software软件"} - - {id: 409, cat: Console, desc: "Games游戏"} - - {id: 411, cat: Books, desc: "Books纪录片"} - - {id: 412, cat: PC/Mobile-Other, desc: "Mobile移动视频"} - - {id: 410, cat: Other, desc: "Misc其他"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Banning rules for account inactivity:
  1. Within 180 days, an account is considered active if it meets any of the following conditions: has checked in for at least 1 day; has published at least 1 seed, fun box or new forum topic (non-reply); actually uploaded increments > 5TiB and the seeding integral increment is > 30W
  2. Users of Megatron level and above ignore the inactivity rules and will keep their accounts forever
  3. Users with Sword Down the Mountain and above will not be disabled due to inactivity after parking their accounts in the control panel
  4. Un-parked accounts below the level of Megatron will be disabled if they have been 'inactive' for 180 consecutive days (Effective from 2024).
  5. Accounts parked below the level of Down the Mountain with a Sword will be disabled if they are 'inactive' for 300 consecutive days (Effective from 2024).
" - -login: - # switched from the form method to the cookie method due to #15184 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - # https://tjupt.org/torrents.php?incldead=0&spstate=0&picktype=0&inclbookmarked=0&search=&search_area=0&search_mode=0 - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 popular, 3 classic, 4 recomended, 5 0day, 6 imdb top 250 - picktype: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30%, 8 special offer, 9 all promotions - spstate: "{{ if .Config.freeleech }}9{{ else }}0{{ end }}" - # 0 title, 1 descr, 2 subtitltes, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href*="cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - filters: - - name: re_replace - args: ["^\\[([^\\]]+)\\]\\[([^\\]]+)\\]\\[([^\\]]+)\\](.*)$", "$3 [$1][$2]$4"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details", "download"] - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor_custom: - selector: span:has(img.arrowup) - optional: true - filters: - - name: replace - args: ["X", ""] - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - span:has(img.arrowup): "{{ .Result.uploadvolumefactor_custom }}" - "*": 1 - minimumratio: - text: 0.8 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 (custom ulvf) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tlfbits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tlfbits.yml deleted file mode 100644 index a0cc61e1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tlfbits.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: tlfbits -name: TLFBits -description: "TLFBits is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.eastgame.org/ - -caps: - categorymappings: - - {id: 438, cat: Movies, desc: "电影 (Movie)"} - - {id: 440, cat: TV, desc: "电视剧(TV series)"} - - {id: 441, cat: TV, desc: "综艺 (TV Show)"} - - {id: 442, cat: TV/Anime, desc: "动漫 (Anime)"} - - {id: 443, cat: TV/Documentary, desc: "纪录片 (Documentary)"} - - {id: 444, cat: TV/Sport, desc: "体育 (Sport)"} - - {id: 445, cat: Audio/Video, desc: "音乐视频 (Music Video)"} - - {id: 446, cat: Audio, desc: "音乐(Music)"} - - {id: 447, cat: PC/Games, desc: "游戏 (Game)"} - - {id: 448, cat: PC/0day, desc: "软件 (Software)"} - - {id: 449, cat: Other/Misc, desc: "资料(E-Learning)"} - - {id: 450, cat: Other, desc: "其它 (Other)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: div.imdb_100 > a - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tmghub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tmghub.yml deleted file mode 100644 index 44b0d97b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tmghub.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: tmghub -name: TmGHuB -description: "TmGHuB (TH) is an INDIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://tmghub.org/ - -caps: - categorymappings: - # Movies - - {id: 29, cat: Movies, desc: "Movie Packs"} - - {id: 30, cat: Movies, desc: "V.I.P"} - - {id: 13, cat: Movies/BluRay, desc: "BD-25/BD-50"} - - {id: 32, cat: Movies/BluRay, desc: "1080p BluRay"} - - {id: 33, cat: Movies/HD, desc: "BDRip / 720p"} - - {id: 11, cat: Movies/DVD, desc: "DVD-R/DVD-DL"} - - {id: 22, cat: Movies/DVD, desc: "Encoded DVDs"} - - {id: 17, cat: Movies/WEB-DL, desc: "WEB-DL/DTH"} - - {id: 14, cat: Movies/DVD, desc: "DVD-Rips"} - - {id: 15, cat: Movies/HD, desc: "DTH/HD-Rip"} - - {id: 45, cat: Movies/Other, desc: "AI Encodes"} - - {id: 20, cat: Movies/SD, desc: "VHS/TV-Rip"} - - {id: 21, cat: Movies, desc: "Video CDs"} - - {id: 19, cat: Movies, desc: "Pre Releases "} - - {id: 42, cat: Movies, desc: "Dubbed Movies"} - - {id: 44, cat: Movies, desc: "TmG Special"} - # Music - - {id: 27, cat: Audio, desc: "MP3/FLAC/WAV"} - - {id: 23, cat: Audio/Video, desc: "Music Videos"} - - {id: 37, cat: Audio, desc: "Music Packs"} - # Television - - {id: 40, cat: TV, desc: "TV Shows"} - - {id: 28, cat: TV, desc: "TV Series"} - - {id: 25, cat: TV/Sport, desc: "Sports"} - - {id: 26, cat: TV/Documentary, desc: "Documentary"} - # Books - - {id: 38, cat: Books/EBook, desc: "E-Books"} - - {id: 34, cat: Books, desc: "Guides / Tutorials"} - # Others - - {id: 5, cat: TV/Anime, desc: "Anime"} - - {id: 39, cat: Other, desc: "Religious"} - - {id: 43, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. Default is 30. - - name: info_grabs - type: info - label: Search results - default: Note that torrents that you have previously downloaded will not be included in results.
You are not allowed to download a torrent twice without informing admin. - - name: thankyou - type: text - label: Thank You Comment - - name: info - type: info - label: Thank you comment - default: This site requires you to leave a Thank You comment before you can download.
Enter your personalised comment above, at least 8 characters long. - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 3 - options: - 3: created - 5: seeders - 4: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -login: - path: index.php?page=login - method: form - form: form[action^="index.php?page=login"] - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: tr td span[style="color:#FF0000;"] - test: - path: index.php - selector: a[href="logout.php"] - -download: - before: - path: "{{ .DownloadUri.AbsoluteUri }}" - method: post - inputs: - comment: "{{ .Config.thankyou }}" - confirm: Confirm - selectors: - - selector: a[href*="download.php?id="] - attribute: href - -search: - paths: - # https://tmghub.org/index.php?page=torrents&search=&category=29;13;32;33;11;22;17;14;15&uploader=0&options=0&active=0&gold=0&langsel=-1 - - path: index.php - inputs: - page: torrents - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - category: "{{ if .Categories }}{{ range .Categories }}{{.}};{{end}}{{ else }}0{{ end }}" - uploader: 0 - # 0=title, 1=title&descr, 2=descr, 3 imdbid - options: "{{ if .Query.IMDBID }}3{{ else }}0{{ end }}" - # 0 all, 1 activeonly, 2 deadonly - active: 0 - # 0 all, 1 classic, 2 silver, 3 gold, 4 silver&gold - gold: "{{ if .Config.freeleech }}3{{ else }}0{{ end }}" - # -1 all, 0 unk, 1 eng, etc - langsel: -1 - order: "{{ .Config.sort }}" - by: "{{ .Config.type }}" - - rows: - selector: "table.torrents tr:has(a[href^=\"index.php?page=torrent-details\"]):not(:has(img[title=\"Allready Grabbed !!\"]))" - - fields: - category: - selector: td.category > a - attribute: href - filters: - - name: querystring - args: category - title: - selector: td.torrent-first a - details: - selector: td.torrent-first a - attribute: href - download: - selector: td.torrent-first a - attribute: href - poster: - selector: td.torrent-first a - attribute: onmouseover - filters: - - name: regexp - args: "src=(.+?) border" - date: - # 6 weeks ago - selector: td.torrent-first a - attribute: onmouseover - filters: - - name: regexp - args: "Added\\:(\\d+ \\w+ \\w+)<" - - name: timeago - size: - selector: td.size - grabs: - selector: td.torrent-first div div - filters: - - name: regexp - args: "^\\d+\\s+\\d+\\s+(.+)" - - name: replace - args: ["---", "0"] - seeders: - selector: td.torrent-first div div - filters: - - name: regexp - args: "^(\\d+)\\s+\\d+\\s+.+" - leechers: - selector: td.torrent-first div div - filters: - - name: regexp - args: "^\\d+\\s+(\\d+)\\s+.+" - downloadvolumefactor: - case: - img[src="gold/gold.gif"]: 0 - img[src="gold/silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 2 hours (as seconds = 2 x 60 x 60) - text: 7200 -# xbtit diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tocashare.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tocashare.yml deleted file mode 100644 index 8a274eb7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tocashare.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: tocashare -name: Toca Share (API) -description: "Toca Share is a PORTUGUESE Private Torrent Tracker for EBOOKS / AUDIOBOOKS / COURSES" -language: pt-PT -type: private -encoding: UTF-8 -links: - - https://tocashare.biz/ # due to expired 31 Mar 2025, new domain tba -legacylinks: - - https://tocashare.com/ # expired 2nd Jan 2025 - -caps: - categorymappings: - - {id: 1, cat: Books/Mags, desc: "Revistas"} - - {id: 2, cat: TV, desc: "Seriado"} - - {id: 3, cat: Audio/Audiobook, desc: "Música"} - - {id: 4, cat: Console, desc: "Jogo"} - - {id: 5, cat: PC, desc: "Aplicativo"} - - {id: 6, cat: Books/EBook, desc: "E-Books"} - - {id: 7, cat: Other, desc: "Cursos"} - - {id: 8, cat: XXX/ImageSet, desc: "Fotos Adultas"} - - {id: 9, cat: Audio/Audiobook, desc: "Audio livro"} - - {id: 10, cat: Books/Comics, desc: "HQs/Mangas"} - - {id: 11, cat: TV, desc: "Shows"} - - modes: - search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Toca Share account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "The inactivation system was created with the purpose of encouraging member participation. This way, after 7 days without accessing the website, 30 BONs will be deducted per day. If your account balance reaches 0 and you don't have any torrents being seeded, it will be automatically deactivated." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - genre: - selector: meta.genres - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 5 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 -# json UNIT3D 9.1.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tokyopt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tokyopt.yml deleted file mode 100644 index cda3093b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tokyopt.yml +++ /dev/null @@ -1,175 +0,0 @@ ---- -id: tokyopt -name: TokyoPT -description: "TokyoPT is a CHINESE Private Torrent Tracker for MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://www.tokyopt.xyz/ - -caps: - categorymappings: - - {id: 409, cat: Other, desc: "其他/Misc"} - - {id: 405, cat: TV/Anime, desc: "动漫/Anime"} - - {id: 414, cat: Movies, desc: "电影/Movies"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 1 days (as seconds = 1 x 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tokyotosho.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tokyotosho.yml deleted file mode 100644 index c0a5fecc..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tokyotosho.yml +++ /dev/null @@ -1,125 +0,0 @@ ---- -id: tokyotosho -name: Tokyo Toshokan -description: "Tokyo Toshokan is a Public BitTorrent Library for JAPANESE Media" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.tokyotosho.info/ -legacylinks: - - https://tokyotosho.proxyportal.fun/ - - https://tokyotosho.uk-unblock.xyz/ - - https://tokyotosho.ind-unblock.xyz/ - - https://tokyotosho.unblocked.bar/ - - https://tokyotosho.proxyportal.pw/ - - https://tokyotosho.uk-unblock.pro/ - - https://tokyotosho.unblocked.rest/ - - https://tokyotosho.unblocked.monster/ - - https://tokyotosho.mrunblock.bond/ - - https://tokyotosho.nocensor.cloud/ - -settings: - - name: cat - type: select - label: Category - default: 0 - options: - 0: All - 1: Anime - 10: Non-English - 3: Manga - 8: Drama - 2: Music - 9: "Music Video" - 7: Raws - 4: Hentai - 12: "Hentai (Anime)" - 13: "Hentai (Manga)" - 14: "Hentai (Games)" - 11: Batch - 15: JAV - 5: Other - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 10, cat: TV/Anime, desc: "Non-English Anime"} - - {id: 3, cat: Books, desc: "Manga"} - - {id: 8, cat: TV/Anime, desc: "Drama"} - - {id: 2, cat: Audio, desc: "Anime Music"} - - {id: 9, cat: TV/Anime, desc: "Anime Music Videos"} - - {id: 7, cat: TV/Anime, desc: "Raw Anime"} - - {id: 4, cat: XXX, desc: "Hentai"} - - {id: 12, cat: XXX, desc: "Hentai (Anime)"} - - {id: 13, cat: XXX, desc: "Hentai (Manga)"} - - {id: 14, cat: XXX, desc: "Hentai (Games)"} - - {id: 11, cat: TV/Anime, desc: "Batch"} - - {id: 15, cat: XXX, desc: "JAV"} - - {id: 5, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep] - music-search: [q] - book-search: [q] - -search: - paths: - - path: "{{ if .Keywords }}search.php{{ else }}index.php{{ end }}" - inputs: - terms: "{{ .Keywords }}" - cat: "{{ .Config.cat }}" - - rows: - selector: "table.listing tr.category_0" - after: 1 - filters: - - name: andmatch - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: regexp - args: (\d+) - title: - selector: td.desc-top a[type="application/x-bittorrent"] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: td.desc-top a[type="application/x-bittorrent"] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - optional: true - size: - selector: td.desc-bot - filters: - - name: split - args: ["|", 1] - - name: regexp - args: "Size: (.+?) ?$" - date: - selector: td.desc-bot - filters: - - name: split - args: ["|", 2] - - name: regexp - args: "Date: (.+?) ?$" - - name: replace - args: ["UTC", "+00:00"] - - name: dateparse - args: "yyyy-MM-dd HH:mm zzz" - seeders: - selector: td.stats > span:nth-child(1) - leechers: - selector: td.stats > span:nth-child(2) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tormac.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tormac.yml deleted file mode 100644 index 07b1252d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tormac.yml +++ /dev/null @@ -1,251 +0,0 @@ ---- -id: tormac -name: Tormac -description: "Tormac is a RUSSIAN Private Torrent Tracker for MAC SOFTWARE" -language: ru-RU -type: private -encoding: windows-1251 -links: - - https://tormac.org/ - -caps: - categorymappings: - # Apple Macintosh - - {id: 10, cat: PC/ISO, desc: "Mac OS на Macintosh"} - # OSX86 Hackintosh - - {id: 46, cat: PC/ISO, desc: "Mac OS на PC"} - # Программы для Мак - - {id: 2, cat: PC/Mac, desc: "Программы - Audio"} - - {id: 3, cat: PC/Mac, desc: "Программы - Video"} - - {id: 4, cat: PC/Mac, desc: "Программы - Graphics"} - - {id: 95, cat: PC/Mac, desc: " |- Вспомогательный инструментарий - presets, effects, brush.."} - - {id: 5, cat: PC/Mac, desc: "Программы - System Utilities"} - - {id: 23, cat: PC/Mac, desc: " |- Средства разработки приложений"} - - {id: 6, cat: PC/Mac, desc: "Программы - Office/PrePress/Database"} - - {id: 7, cat: PC/Mac, desc: "Программы - Network / WEB"} - - {id: 63, cat: PC/Mac, desc: " |- Веб-разработка, инструментарий"} - - {id: 8, cat: PC/Mac, desc: "Программы - 3D/CAD/Engineering"} - - {id: 9, cat: PC/Mac, desc: "Игры/Games"} - - {id: 64, cat: PC/Mac, desc: "Разное/Other"} - - {id: 91, cat: PC/Mac, desc: " |- Патчи и другой инструментарий"} - # Книги, руководства по Apple - - {id: 30, cat: Books, desc: "Книги, Журналы"} - - {id: 31, cat: Other, desc: "Видео Apple / Обучающее видео"} - - {id: 103, cat: Other, desc: " |- Видеоуроки"} - - {id: 104, cat: Other, desc: " |- Компьютерные видеоуроки"} - # iPhone / iPod / iPad / Apple TV - - {id: 32, cat: PC/Mobile-iOS, desc: "Программы для iOS"} - - {id: 97, cat: PC/Mobile-iOS, desc: "Игры для iOS"} - - {id: 33, cat: PC/Mobile-iOS, desc: "iOS. Анлок, джейлбрейк и т.п."} - # Apple Audio - - {id: 36, cat: Audio/Audiobook, desc: "Аудиокниги (AAC/ALAC)"} - - {id: 34, cat: Audio/Lossless, desc: "Музыка Lossless (ALAC)"} - - {id: 101, cat: Audio/MP3, desc: "Музыка Lossy (AAC-iTunes)"} - - {id: 35, cat: Audio/MP3, desc: "Музыка Lossy (AAC)"} - - {id: 102, cat: Audio/MP3, desc: "Музыка Lossy (AAC) (Singles, EPs)"} - # Видео для iPod / iPad / iPhone / AppleTV - - {id: 37, cat: Movies/SD, desc: "AppleTV - SD"} - - {id: 38, cat: Movies/SD, desc: " |- Фильмы для iPod, iPhone, iPad"} - - {id: 39, cat: TV/SD, desc: " |- Сериалы для iPod, iPhone, iPad"} - - {id: 98, cat: TV/Other, desc: " |- Мультфильмы для iPod, iPhone, iPad"} - - {id: 99, cat: TV/Anime, desc: " |- Аниме для iPod, iPhone, iPad"} - - {id: 40, cat: Audio/Video, desc: " |- Музыкальное видео для iPod, iPhone, iPad"} - - {id: 41, cat: TV/HD, desc: "AppleTV - HD"} - - {id: 42, cat: Movies/HD, desc: " |- Фильмы HD для Apple TV"} - - {id: 92, cat: TV/Other, desc: " |- Мультфильмы HD для Apple TV"} - - {id: 94, cat: TV/Other, desc: " |- Мультсериалы HD для Apple TV"} - - {id: 93, cat: TV/Anime, desc: " |- Аниме HD для Apple TV"} - - {id: 43, cat: TV/HD, desc: " |- Сериалы HD для Apple TV"} - - {id: 100, cat: TV/Documentary, desc: " |- Документальное видео HD для Apple TV"} - - {id: 44, cat: Audio/Video, desc: " |- Музыкальное видео HD для Apple TV"} - # Разное - - {id: 55, cat: Other/Misc, desc: "Тестовый раздел"} - - {id: 56, cat: Other/Misc, desc: " |- Раздачи девелоперских релизов"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: freeleech_50 - type: checkbox - label: Search freeleech and 50% freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts are automatically deleted after 90 days from the moment the system first detects inactivity.
A user is considered inactive if they meet one of the following conditions:
  • Within 30 days of registration, they have not downloaded at least 1 torrent, resulting in Total Downloaded in their profile being 0
  • Their ratio is less than or equal to 0.3
  • They have fewer than 10 time bonuses
Immunity from inactivity is automatically granted to users holding any title from the list or being a member of groups." - -login: - # using cookie method because I could not get the form method to work #15131 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: "a[onclick=\"return post2url('login.php', {logout: 1});\"]" - -search: - paths: - - path: tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_my: 0 - prev_new: 0 - prev_oop: 0 - prev_sg: 1 - # sort by 1=registered 2=topicTitle 4=completed 10=seed 11=leech 12=up 13=down 5=replies 6=views 7=size 8=lastPost 9=seedLastSeen - o: "{{ .Config.sort }}" - # 1=asc 2=desc - s: "{{ .Config.type }}" - # -1=all time - tm: -1 - nm: "{{ .Keywords }}" - sg: "{{ if .Config.freeleech_50 }}1{{ else }}{{ end }}" - - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - - rows: - selector: tr.tCenter:has(a[href^="dl.php?t="]) - - fields: - category: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a[href^="./viewtopic.php?t="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: re_replace - args: ["(?i)^\\(\\s*([SE]\\d+.*?)\\s*\\)[\\s\\/\\|]*(.+)", "$2 $1"] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="./viewtopic.php?t="] - attribute: href - download: - selector: a[href^="dl.php?t="] - attribute: href - size: - selector: td.tor-size > u - seeders: - selector: td[title="Сиды"] b - filters: - - name: replace - args: ["никогда", "0"] - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - selector: td[title="Добавлен"] > u - downloadvolumefactor: - case: - img[src$="default/images/tor_gold.gif"]: 0 - img[src$="default/images/tor_silver.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.31 - description_verified: - case: - span.tor-approved: "Verified:" - span.tor-not-approved: "Unverified:" - span.tor-dup: "Temporary:" - description: - selector: a[href^="./viewtopic.php?t="] - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# TorrentPier II diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tornado.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tornado.yml deleted file mode 100644 index b75e1807..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tornado.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: tornado -name: Tornado -description: "Tornado is a SLOVENIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: sl-SI -type: private -encoding: UTF-8 -links: - - https://www.tornado.si/ - -caps: - categorymappings: - - {id: 32, cat: Movies/SD, desc: "Filmi Xvid"} - - {id: 33, cat: Movies/DVD, desc: "Filmi DVD"} - - {id: 34, cat: Movies/BluRay, desc: "Filmi BluRay"} - - {id: 35, cat: TV/Documentary, desc: "Dokumentarci Filmi"} - - {id: 36, cat: TV/Anime, desc: "Risanke"} - - {id: 37, cat: Audio/Video, desc: "Koncerti"} - - {id: 38, cat: Audio/MP3, desc: "Glasba MP3"} - - {id: 39, cat: Audio/Lossless, desc: "Glasba FLAC"} - - {id: 40, cat: PC/Games, desc: "Igre PC"} - - {id: 41, cat: Console/PS3, desc: "Igre PS3"} - - {id: 42, cat: Console/PS4, desc: "Igre PS4"} - - {id: 43, cat: Console/XBox, desc: "Igre Xbox"} - - {id: 47, cat: Console/NDS, desc: "Igre Nintendo"} - - {id: 44, cat: PC, desc: "Programi"} - - {id: 45, cat: Books/EBook, desc: "E-Knjige"} - - {id: 46, cat: Books/Mags, desc: "E-Revije"} - - {id: 48, cat: TV, desc: "TV-Rip"} - - {id: 49, cat: XXX/ImageSet, desc: "Slike"} - - {id: 50, cat: XXX/DVD, desc: "Erotika"} - - {id: 51, cat: Other/Misc, desc: "Ostalo"} - - {id: 52, cat: Other, desc: "VIP"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error Found no results while trying to browse this tracker then first access the site with your browser and check that you are not being forced to change your password because it has expired after 180 days." - -login: - path: ?p=home&pid=1 - method: form - form: form#sls_form - submitpath: ajax/login.php - inputs: - action: login - loginbox_membername: "{{ .Config.username }}" - loginbox_password: "{{ .Config.password }}" - loginbox_remember: 1 - selectorinputs: - securitytoken: - selector: "script:contains(\"stKey: \")" - filters: - - name: regexp - args: "stKey: \"(.+?)\"," - error: - - selector: div.error - - selector: :contains("-ERROR-") - test: - path: ?p=home&pid=1 - selector: div#member_info_bar - -search: - paths: - - path: / - keywordsfilters: - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%25"] - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - # does not support imdbid search but has imdbid links in results. - error: - - selector: div.error:not(:contains("There are no results found.")) - - rows: - selector: "div.torrent-box[id^=\"torrent_\"]{{ if .Config.freeleech }}:has(img[src$=\"/torrent_free.png\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="?p=torrents&pid=10&cid="] - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - details: - selector: a[href*="?p=torrents&pid=10&action=details&tid="] - attribute: href - download: - selector: a[href*="?p=torrents&pid=10&action=download&tid="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - optional: true - poster: - selector: a[href*="data/torrents/torrent_images/"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - size: - selector: a[rel="torrent_size"] - grabs: - selector: a[rel="times_completed"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - date_day: - selector: div.torrentOwner:contains("day") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - selector: div.torrentOwner:contains("-") - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - selector: div.torrentOwner abbr - optional: true - attribute: data-time - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img[src$="/torrent_free.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 2 day (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torr9.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torr9.yml deleted file mode 100644 index f61ce8d3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torr9.yml +++ /dev/null @@ -1,150 +0,0 @@ ---- -id: torr9 -name: Torr9 -description: "Torr9 is a FRENCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://torr9.net/ -legacylinks: - - https://torr9.xyz/ - - https://torr9.com/ - -caps: - categorymappings: - # from https://api.torr9.xyz/api/v1/torznab?apikey=YOUR-PASSKEY&t=caps - - {id: 1000, cat: Console, desc: "Console/Games"} - - {id: 2000, cat: Movies, desc: "Movies"} - - {id: 3000, cat: Audio, desc: "Audio"} - - {id: 4000, cat: PC, desc: "PC/Applications"} - - {id: 5000, cat: TV, desc: "TV"} - - {id: 5000, cat: TV/Anime, desc: "Animes"} - - {id: 6000, cat: XXX, desc: "XXX"} - - {id: 7000, cat: Books, desc: "Books/eBook"} - - {id: 8000, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, tvdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: passkey - type: text - label: PassKey - - name: info_key - type: info - label: About your PassKey - default: "You can find the API PassKey by accessing your Torr9 Profile page and scrolling down to the Passkey Tracker section." - - name: apiurl - label: API URL - type: text - default: api.torr9.net - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi.FRENCH: MULTi.FRENCH - ENGLISH: ENGLISH - MULTi.ENGLISH: MULTi.ENGLISH - VOSTFR: VOSTFR - MULTi.VOSTFR: MULTi.VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: info_3x - type: info - label: Including 3X - default: "IF you want to see 3x torrents access your Torr9 Profile page and enabling the Contenu adulte option." - -login: - # returns "401 Unauthorized, check your credentials" if passkey is invalid - path: "https://{{ .Config.apiurl }}/api/v1/torznab?apikey={{ .Config.passkey }}&t=caps" - method: get - -search: - paths: - - path: "https://{{ .Config.apiurl }}/api/v1/torznab" - response: - type: xml - - inputs: - apikey: "{{ .Config.passkey }}" - t: "{{ if .Query.IMDBID }}movie{{ else }}{{ end }}{{ if .Query.TVDBID }}tvsearch{{ else }}{{ end }}{{ if or .Query.TVDBID .Query.IMDBID }}{{ else }}search{{ end }}" - q: "{{ .Keywords }}" - cat: "{{ join .Categories \",\" }}" -# season: "{{ .Query.Season }}" -# ep: "{{ .Query.Ep }}" - imdbid: "{{ .Query.IMDBID }}" - tvdbid: "{{ .Query.TVDBID }}" - limit: 100 - - keywordsfilters: - # replace spaces between words with percent wildcard - - name: re_replace - args: ["\\W+", "%"] - - rows: - selector: rss > channel > item - - fields: - category: - selector: '[name="category"]' - attribute: value - title_phase1: - selector: title - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR|VF2|VFF|VFQ|VOQ|VFI|VOF)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: comments - filters: - - name: replace - args: ["//api.", "//"] - download: - selector: link - infohash: - selector: '[name="infohash"]' - attribute: value - date: - # Thu, 15 Jan 2026 16:24:34 +0000 - selector: pubDate - filters: - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - size: - selector: size - seeders: - selector: '[name="seeders"]' - attribute: value - leechers: - selector: '[name="peers"]' - attribute: value - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-heaven.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-heaven.yml deleted file mode 100644 index fec78127..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-heaven.yml +++ /dev/null @@ -1,189 +0,0 @@ ---- -id: torrent-heaven -name: Torrent Heaven -description: "Torrent Heaven is a DUTCH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: nl-NL -type: private -encoding: UTF-8 -links: - - https://www.torrentheaven.org/ - -caps: - categorymappings: - - {id: 129, cat: Movies/HD, desc: "HD MKV"} - - {id: 123, cat: Movies/HD, desc: "X265/HEVC"} - - {id: 130, cat: Movies/UHD, desc: "4K Films"} - - {id: 10, cat: Movies/DVD, desc: "Dvd Retail"} - - {id: 111, cat: Movies/SD, desc: "HD2DVD / BR2DVD"} - - {id: 125, cat: Movies/SD, desc: "HD rip"} - - {id: 143, cat: Movies/HD, desc: "Bluray/UHD"} - - {id: 132, cat: TV, desc: "complete seizoen serie"} - - {id: 139, cat: TV, desc: "Losse afleveringen"} - - {id: 140, cat: Books/EBook, desc: "E-Book"} - - {id: 114, cat: Audio/MP3, desc: "Muziek MP3"} - - {id: 105, cat: TV/Documentary, desc: "Documentaire"} - - {id: 142, cat: Audio/Lossless, desc: "Muziek Flac"} - - {id: 115, cat: PC, desc: "pc apps"} - - {id: 120, cat: PC/Games, desc: "Pc Games"} - - {id: 106, cat: Other, desc: "Overig"} - - {id: 131, cat: Books/Comics, desc: "Stripboeken"} - - {id: 108, cat: XXX, desc: "18+ Adult"} - - {id: 116, cat: PC/Mac, desc: "Mac"} - - {id: 155, cat: Books/Mags, desc: "Tijdschriften"} - - {id: 128, cat: Audio/Video, desc: "muziek DVD"} - - {id: 156, cat: Other, desc: "Donateur"} - - {id: 118, cat: Movies, desc: "Kids Film"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: thankyou - type: text - label: Thank You Comment - - name: info - type: info - label: Thank you comment - default: This site requires you to leave a Thank You comment before you can download. Enter your personalised comment above. - - name: info_activity - type: info - label: Account Inactivity - default: "Your account will be deleted after 90 days of not logging in to the site." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: "/" - error: - - selector: td.embedded:contains("fout") - test: - path: / - selector: a[href="logout.php"] - -download: - before: - path: comment.php - method: post - inputs: - action: add - tid: "{{ .DownloadUri.Query.id }}" - text: "{{ .Config.thankyou }}" - submit: Opslaan - selectors: - - selector: a[href^="download.php?id="] - attribute: href - - selector: a[href^="downloadv1.php?id="] - attribute: href - -search: - paths: - # https://www.torrentheaven.org/browse.php?search=&cat=0&incldead=1 - - path: browse.php - keywordsfilters: - # if searching for season packs switch S01 to seizoen 1 - - name: re_replace - args: ["(?i)(S0)(\\d{1,2})$", "seizoen $2"] - - name: re_replace - args: ["(?i)(S)(\\d{1,3})$", "seizoen $2"] - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - incldead: 1 - - rows: - selector: table.mainouter - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - poster: - selector: div[id^="Style"] img - attribute: src - size: - selector: a[href^="details_bestanden.php?"] - filters: - - name: regexp - args: "(.+?) in" - files: - selector: a[href^="details_bestanden.php?"] - filters: - - name: regexp - args: "in (\\d+) bestan" - grabs: - selector: td:nth-child(5) table tbody tr:nth-child(2) td:first-child - date: - selector: td:nth-child(5) table tbody tr:nth-child(2) td:last-child - filters: - - name: replace - args: ["januari", "January"] - - name: replace - args: ["februari", "February"] - - name: replace - args: ["maart", "March"] - - name: replace - args: ["april", "April"] - - name: replace - args: ["mei", "May"] - - name: replace - args: ["juni", "June"] - - name: replace - args: ["juli", "July"] - - name: replace - args: ["augustus", "August"] - - name: replace - args: ["september", "September"] - - name: replace - args: ["oktober", "October"] - - name: replace - args: ["november", "November"] - - name: replace - args: ["december", "December"] - - name: re_replace - args: ["\\s*om\\s*", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "d MMMM yyyy HH:mm:ss zzz" - seeders: - selector: td:nth-child(5) table tbody tr:last-child td:nth-last-child(3) font b:first-child - optional: true - default: 0 - leechers: - selector: td:nth-child(5) table tbody tr:last-child td:nth-last-child(3) font b:nth-child(2) - optional: true - default: 0 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-pirat.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-pirat.yml deleted file mode 100644 index 53ec9900..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-pirat.yml +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: torrent-pirat -name: torrent-pirat -description: "torrent-pirat is a RUSSIAN Public Torrent Tracker for 3X" -language: ru-RU -type: public -encoding: UTF-8 -requestDelay: 5.1 -links: - - http://www.torrent-pirat.com/ # site does not support https ERR_CONNECTION_REFUSED - -caps: - categorymappings: - - {id: 1, cat: XXX, desc: "Трекер"} - - {id: 6, cat: XXX, desc: " Полнометражные XXX фильмы / Full Length Movies"} - - {id: 7, cat: XXX, desc: " Классика / Classic (Полнометражные фильмы снятые до 2000 года)"} - - {id: 8, cat: XXX, desc: " Фильмы с сюжетом / Feature Vignettes"} - - {id: 9, cat: XXX, desc: " Порно-пародия / Parody"} - - {id: 11, cat: XXX, desc: " Компиляции и фильмы без сюжета / Movies without a story"} - - {id: 36, cat: XXX, desc: " Фильмы с Русским переводом"} - - {id: 37, cat: XXX, desc: " Эротические фильмы / Erotic Movies"} - - {id: 38, cat: XXX, desc: " Документальные фильмы 18+ / Documentary 18+"} - - {id: 39, cat: XXX, desc: " Паки полнометражных фильмов / Full Length Movies Packs"} - - {id: 12, cat: XXX, desc: " Русское порно / Russian - Фильмы и ролики"} - - {id: 13, cat: XXX, desc: " Порнофильмы с Русскими актрисами / Movies with Russian actresses"} - - {id: 14, cat: XXX, desc: " Сцены и ролики с Русскими актрисами / Russian Movie Scenes &SiteRip's"} - - {id: 15, cat: XXX, desc: " Паки видео с Русскими актрисами / Russian Video Packs"} - - {id: 28, cat: XXX, desc: " Зарубежные ролики по жанрам / Foreign videos by genre"} - - {id: 27, cat: XXX, desc: " Сцены из фильмов / Movie Scenes"} - - {id: 26, cat: XXX, desc: " Порно-кастинги / Casting"} - - {id: 19, cat: XXX, desc: " Family Roleplay, Incest &Taboo"} - - {id: 21, cat: XXX, desc: " Public &Reality, Sex In Car, Outdoors"} - - {id: 20, cat: XXX, desc: " Анал и ДП / Anal &DP"} - - {id: 29, cat: XXX, desc: " Секс втроём / Threesomes"} - - {id: 30, cat: XXX, desc: " Группавуха / Group &Gangbang"} - - {id: 35, cat: XXX, desc: " Молоденькие / Legal Teens"} - - {id: 32, cat: XXX, desc: " Мамочки / MILF &Mature"} - - {id: 33, cat: XXX, desc: " Бабушки / Grannies"} - - {id: 40, cat: XXX, desc: " Pornstars &Other"} - - {id: 34, cat: XXX, desc: " Паки порнороликов / Video Packs"} - - {id: 64, cat: XXX, desc: " Этническое порно / Ethnic Porn - Фильмы и ролики"} - - {id: 65, cat: XXX, desc: " Межрасовый секс / Interracial - Сцены и ролики"} - - {id: 68, cat: XXX, desc: " Негритянки и Темнокожие / Ebony &Arab - Сцены и ролики"} - - {id: 66, cat: XXX, desc: " Азиатки / Asian - Сцены и ролики"} - - {id: 67, cat: XXX, desc: " Латинки / Latin - Сцены и ролики"} - - {id: 83, cat: XXX, desc: " Полнометражные азиатские и этнические фильмы / Full-length ethnic films"} - - {id: 105, cat: XXX, desc: " Этническое порно / Ethnic Porn - Паки роликов и фильмов"} - - {id: 84, cat: XXX, desc: " Оральный секс / Blowjob &Deep Throat, Licking - Фильмы и ролики"} - - {id: 86, cat: XXX, desc: " Оральный секс / Blowjob &Deep Throat, Licking - Сцены и ролики"} - - {id: 87, cat: XXX, desc: " Групповой оральный секс / Blowbang &Bukkake - Сцены и ролики"} - - {id: 85, cat: XXX, desc: " Оральный секс / Blowjob &Deep Throat, Licking - Полнометражные фильмы"} - - {id: 61, cat: XXX, desc: " Masturbation, Solo &Posing - Фильмы и ролики"} - - {id: 62, cat: XXX, desc: " Мастурбация / Masturbation"} - - {id: 63, cat: XXX, desc: " Posing &Striptease"} - - {id: 2, cat: XXX, desc: " Любительское порно / Amateur &Homemade"} - - {id: 3, cat: XXX, desc: " Русское любительское XXX видео / Russian Amateur"} - - {id: 18, cat: XXX, desc: " Зарубежное любительское порно / Foreign amateur"} - - {id: 4, cat: XXX, desc: " Порно чаты / WEBCam"} - - {id: 10, cat: XXX, desc: " Скрытые камеры / Spycam"} - - {id: 5, cat: XXX, desc: " Паки любительского порно / Amateur porn packs"} - - {id: 110, cat: XXX, desc: " Только для зарегистрированных / For Registered Members Only"} - - {id: 112, cat: XXX, desc: " Полнометражные фильмы / Full-length Movies"} - - {id: 113, cat: XXX, desc: " Сцены и ролики / Scenes &Videos"} - - {id: 114, cat: XXX, desc: " Анимация 18+ / Animation 18+"} - - {id: 116, cat: XXX, desc: " Игры для взрослых / Porn games"} - - {id: 115, cat: XXX, desc: " Фото, Обои, Картинки / Photos, Wallpapers &Pictures"} - - {id: 16, cat: XXX, desc: "Фетиш и Нетрадиционное порно / Special Interest Movies &Clips"} - - {id: 43, cat: XXX, desc: " Rough Sex &BDSM - Фильмы и ролики"} - - {id: 17, cat: XXX, desc: " Rough Sex, BDSM &Bondage - Полнометражные фильмы"} - - {id: 46, cat: XXX, desc: " Постановочные изнасилования / Pseudo-Rape - Сцены и ролики"} - - {id: 41, cat: XXX, desc: " Rough Sex, BDSM &Bondage - Сцены и ролики"} - - {id: 45, cat: XXX, desc: " Ролики и фильмы от "LegalPorno ""} - - {id: 44, cat: XXX, desc: " Fisting &Dildo - Сцены и ролики"} - - {id: 47, cat: XXX, desc: " Фетиш / Fetish - Фильмы и ролики"} - - {id: 49, cat: XXX, desc: " Фетиш / Fetish - Полнометражные фильмы"} - - {id: 109, cat: XXX, desc: " Cuckold / Cheating - Сцены и ролики"} - - {id: 99, cat: XXX, desc: " Alt-Porn / Alt Girls - Сцены и ролики"} - - {id: 106, cat: XXX, desc: " Cosplay - Сцены и ролики"} - - {id: 53, cat: XXX, desc: " Fetish Wear / Latex, Uniform, Panties, Lingerie, Stockings - Сцены и ролики"} - - {id: 54, cat: XXX, desc: " Большие сиськи и жопы / Big Boobs &Big Ass - Сцены и ролики"} - - {id: 48, cat: XXX, desc: " Foot Fetish, Footjob &Feet - Сцены и ролики"} - - {id: 55, cat: XXX, desc: " Толстушки / BBW &Plump Woman - Сцены и ролики"} - - {id: 56, cat: XXX, desc: " Худенькие и Миниатюрные / Skinny &Petite - Сцены и ролики"} - - {id: 60, cat: XXX, desc: " Массаж / Massage - Сцены и ролики"} - - {id: 98, cat: XXX, desc: " Wet &Oiled - Сцены и ролики"} - - {id: 51, cat: XXX, desc: " Femdom &Domination - Сцены и ролики"} - - {id: 50, cat: XXX, desc: " Мохнатки / Hairy Pussy &Bush - Сцены и ролики"} - - {id: 57, cat: XXX, desc: " Squirting / Pissing &Pee - Сцены и ролики"} - - {id: 52, cat: XXX, desc: " Glory Hole - Сцены и ролики"} - - {id: 107, cat: XXX, desc: " Medical Fetish - Сцены и ролики"} - - {id: 58, cat: XXX, desc: " Беременные / Pregnant - Сцены и ролики"} - - {id: 104, cat: XXX, desc: " Пьяные и Спящие / Drunk &Sleeping - Сцены и ролики"} - - {id: 59, cat: XXX, desc: " Карлики / Midgets - Сцены и ролики"} - - {id: 108, cat: XXX, desc: " Разное / Various Fetishes - Сцены и ролики"} - - {id: 69, cat: XXX, desc: " Лесбиянки / Lesbians - Фильмы и ролики"} - - {id: 70, cat: XXX, desc: " Полнометражные лесби-фильмы / Full length lesbian movies"} - - {id: 71, cat: XXX, desc: " Лесбиянки / Lesbians - Сцены и ролики"} - - {id: 72, cat: XXX, desc: " Лесбийский страпон / Lesbian Strap-on - Сцены и ролики"} - - {id: 73, cat: XXX, desc: " All Girl / Lesbians - Video Pack"} - - {id: 42, cat: XXX, desc: " Fetish, Rough Sex &BDSM - Video Pack"} - - {id: 24, cat: XXX, desc: "Анимация, игры, фото и картинки"} - - {id: 23, cat: XXX, desc: " Анимация 18+ / Animation 18+"} - - {id: 74, cat: XXX, desc: " Мультфильмы18+ / Cartoons 18+"} - - {id: 75, cat: XXX, desc: " Хентай 18+ / Hentai 18+"} - - {id: 76, cat: XXX, desc: " Паки мультфильмов 18+ / Animation packs 18+"} - - {id: 77, cat: XXX, desc: " Фото, Обои и Картинки / Photos, Wallpapers &Pictures"} - - {id: 79, cat: XXX, desc: " Любительское фото / Amateur photo"} - - {id: 80, cat: XXX, desc: " Подборки сетов / Picture Sets Packs"} - - {id: 81, cat: XXX, desc: " Подборки по актрисам / Actresses Picture Packs"} - - {id: 82, cat: XXX, desc: " Сборники фото и обоев, разное / Collections of Photos &Wallpapers"} - - {id: 78, cat: XXX, desc: " Игры 18+ / Porn games"} - - {id: 102, cat: XXX, desc: " Визуальные новеллы / Visual Novels"} - - {id: 103, cat: XXX, desc: " Ролевые / Role-Playing"} - - {id: 88, cat: XXX, desc: "Общий форум"} - - {id: 100, cat: XXX, desc: " Правила &FAQ"} - - {id: 89, cat: XXX, desc: " Новости и объявления"} - - {id: 90, cat: XXX, desc: " Тех. раздел"} - - {id: 91, cat: XXX, desc: " Стол заказов"} - - {id: 92, cat: XXX, desc: " Помогите опознать!"} - - {id: 93, cat: XXX, desc: " Свободное общение"} - - {id: 94, cat: XXX, desc: " Форум релизёров"} - - {id: 95, cat: XXX, desc: " Скриншоты"} - - {id: 96, cat: XXX, desc: " MediaInfo"} - - {id: 97, cat: XXX, desc: " Разное"} - - {id: 101, cat: XXX, desc: " Архив"} - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: t - options: - t: created - ts: seeders - sz: size - i: title - - name: type - type: select - label: Order requested from site - default: d - options: - d: desc - a: asc - -search: - # http://www.torrent-pirat.com/search.php?tracker_search=torrent&keywords=2023&terms=all&fp=1&author=&sc=1&sf=titleonly&sk=t&sd=d&sr=topics&st=0&ch=300&t=0&submit=Search - paths: - - path: search.php - inputs: - tracker_search: torrent - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - terms: all - fp: 1 - sc: 1 - sf: titleonly - sr: topics - sk: "{{ .Config.sort }}" - sd: "{{ .Config.type }}" - st: 0 - ch: 300 - t: 0 - submit: Search - $raw: "{{ range .Categories }}&fid[]={{.}}{{end}}" - - rows: - selector: table.tablebg:nth-of-type(2) > tbody > tr:has(a[href^="./download/file.php?id="]) - - fields: - category: - text: 1 - title: - selector: a.topictitle - details: - selector: a.topictitle - attribute: href - download: - selector: a[href^="./download/file.php?id="] - attribute: href - size: - selector: p.gensmall - filters: - - name: regexp - args: "Размер: (.+?)," - - name: replace - args: ["ТБ", "TB"] - - name: replace - args: ["ГБ", "GB"] - - name: replace - args: ["МБ", "MB"] - - name: replace - args: ["КБ", "KB"] - seeders: - selector: span.seed - leechers: - selector: span.leech - grabs: - selector: span.complet - date: - text: now - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# phpBB diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-turk.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-turk.yml deleted file mode 100644 index 4d3474b2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent-turk.yml +++ /dev/null @@ -1,207 +0,0 @@ ---- -id: torrent-turk -name: TOrrent-tuRK -description: "TOrrent-tuRK (TORK) is a TURKISH Private Torrent Tracker for HD MOVIES / TV / GENERAL" -language: tr-TR -type: private -encoding: UTF-8 -links: - - https://torrent-turk.de/ -legacylinks: - - https://torrent-turk.org/ - - https://torrent-turk.co/ - -caps: - categorymappings: - - {id: 149, cat: Movies, desc: "Movies/Turkish"} - - {id: 151, cat: Movies/HD, desc: "Movies/Turkish/1080p"} - - {id: 152, cat: Movies/HD, desc: "Movies/Turkish/720p"} - - {id: 153, cat: Movies/DVD, desc: "Movies/Turkish/DVD5-DVD9"} - - {id: 154, cat: Movies/WEB-DL, desc: "Movies/Turkish/BRRip-HDRip-DVDRip-WebDL"} - - {id: 155, cat: Movies/Other, desc: "Movies/Turkish/Boxset"} - - {id: 156, cat: Movies, desc: "Movies/Foreign"} - - {id: 157, cat: Movies/UHD, desc: "Movies/Foreign/4K / 3D"} - - {id: 159, cat: Movies/HD, desc: "Movies/Foreign/1080p"} - - {id: 160, cat: Movies/HD, desc: "Movies/Foreign/720p"} - - {id: 161, cat: Movies/DVD, desc: "Movies/Foreign/DVD5-DVD9"} - - {id: 162, cat: Movies/WEB-DL, desc: "Movies/Foreign/BRRip-HDRip-DVDRip-WebDL"} - - {id: 163, cat: Movies/Other, desc: "Movies/Foreign/Boxset"} - - {id: 164, cat: TV, desc: "TV"} - - {id: 165, cat: TV, desc: "TV/Turkish"} - - {id: 166, cat: TV, desc: "TV/Foreign"} - - {id: 167, cat: TV/Other, desc: "TV Programs"} - - {id: 185, cat: TV/Documentary, desc: "TV/Documentary"} - - {id: 168, cat: TV/Other, desc: "TV/Other"} - - {id: 169, cat: TV/Other, desc: "TV/Boxset"} - - {id: 171, cat: Audio, desc: "Music"} - - {id: 172, cat: Audio, desc: "Music/Turkish"} - - {id: 173, cat: Audio, desc: "Music/Foreign"} - - {id: 184, cat: Audio, desc: "Music/Discography"} - - {id: 174, cat: Audio/Video, desc: "Music/Video"} - - {id: 191, cat: TV, desc: "TV/Max"} - - {id: 192, cat: TV, desc: "TV/Max Series"} - - {id: 193, cat: Movies, desc: "Movies/Max Film"} - - {id: 188, cat: TV, desc: "TV/Netflix"} - - {id: 189, cat: TV, desc: "TV/Netflix Series"} - - {id: 190, cat: Movies, desc: "Movies/Netflix Film"} - - {id: 195, cat: TV, desc: "TV/Exxen"} - - {id: 195, cat: Movies, desc: "TV/Exxen"} - - {id: 201, cat: TV, desc: "TV/Tabii"} - - {id: 201, cat: Movies, desc: "TV/Tabii"} - - {id: 199, cat: TV, desc: "TV/GAİN"} - - {id: 199, cat: Movies, desc: "TV/GAİN"} - - {id: 200, cat: TV, desc: "Movies/Disney+"} - - {id: 200, cat: Movies, desc: "Movies/Disney+"} - - {id: 198, cat: TV, desc: "Movies/Amazon Prime"} - - {id: 198, cat: Movies, desc: "Movies/Amazon Prime"} - - {id: 202, cat: TV, desc: "TV/Apple TV+"} - - {id: 202, cat: Movies, desc: "TV/Apple TV+"} - - {id: 175, cat: PC, desc: "Apps / Game / Graphics"} - - {id: 176, cat: PC, desc: "Apps"} - - {id: 177, cat: Other, desc: "Pictures"} - - {id: 183, cat: Books/EBook, desc: "EBook"} - - {id: 178, cat: Books/Technical, desc: "Training Sets"} - - {id: 181, cat: Console, desc: "Playstation"} - - {id: 179, cat: PC, desc: "OS"} - - {id: 180, cat: PC/Games, desc: "PC/Games"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: info - type: info - label: Layout - default: "
  • Only the English Classic profile is supported.
  • Make sure to set the Torrent Listing (Listeleme Biçimi) option in your profile to Classic (Klasik)
  • And set the Language (Dil) to English
  • Using the Modern theme will prevent results, and using Turkish will prevent upload dates.
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_results - type: info - label: Search results - default: "If you are getting the error: An error has occurred!You can't view this page unless you read all your unread messages, then access the site with your browser and mark as read all PMs." - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: ?p=torrents&type=bookmarks&pid=508 - selector: a#logout - -search: - paths: - - path: / - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name, description, both, uploader - search_type: name - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - error: - - selector: div.error:not(:contains("Hiçbir sonuç bulunamadı.")):not(:contains("There are no results found.")):not(:contains("Click on The Flag to Change")) - # does not support imdbid search and does not return imdb link in results. - - rows: - selector: "table#torrents_table_classic > tbody > tr:has(td.torrent_name){{ if .Config.freeleech }}:has(img[title=\"FREE!\"]){{ else }}{{ end }}" - filters: - - name: andmatch - - fields: - category: - selector: div.category_image > a - attribute: href - filters: - - name: querystring - args: cid - title: - selector: td.torrent_name > a - details: - selector: td.torrent_name > a - attribute: href - download: - selector: td.download > a - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - date_day: - # Uploaded Friday at 05:11 by - # Uploaded Today at 00:48 by - # Uploaded Yesterday at 23:57 by - selector: td.torrent_name:contains(" at "):not(:has(abbr[data-time])) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - # Uploaded 30-01-2019 15:02 by - selector: td.torrent_name:not(:contains(" at ")):not(:has(abbr[data-time])) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - # within the hour (unix) - selector: abbr[data-time] - attribute: data-time - optional: true - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - size: - selector: a[rel="torrent_size"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - grabs: - selector: a[rel="times_completed"] - poster: - selector: a[rel="fancybox"] - attribute: href - downloadvolumefactor: - case: - "img[title=\"FREE!\"]": 0 - "img[title=\"Download Multiplier: 0.5\"]": 0.5 - "*": 1 - uploadvolumefactor: - case: - "img[title=\"Upload Multiplier: 2\"]": 2 - "*": 1 - minimumratio: - text: 0.7 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent9.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent9.yml deleted file mode 100644 index 4e88439d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrent9.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: torrent9 -name: Torrent9 -description: "Torrent9 is a FRENCH Public site for MOVIES / TV / GENERAL" -language: fr-FR -type: public -encoding: UTF-8 -followredirect: true -testlinktorrent: false -links: - - https://www5.torrent9.to/ -legacylinks: - - https://www.torrent9.pl/ # this is a proxy for torrent9clone - - https://torrent9.black-mirror.xyz/ # this is a proxy for torrent9clone - - https://torrent9.unblocked.casa/ # this is a proxy for torrent9clone - - https://torrent9.proxyportal.fun/ # this is a proxy for torrent9clone - - https://torrent9.uk-unblock.xyz/ # this is a proxy for torrent9clone - - https://torrent9.ind-unblock.xyz/ # this is a proxy for torrent9clone - - https://ww1.torrent9.is/ - - https://ww1.torrent9.to/ - - https://www.torrent9.is/ - - https://torrent9.li/ # not a proxy for torrent9 or torrent9clone - - https://www.oxtorrent.me/ - - https://www.torrent9.gg/ - - https://www.torrent9.fi/ # this is the torrent9clone domain - - https://www.torrent9.fm/ - - https://torrent9.se/ # redirect to www. - - https://torrent9.ninjaproxy1.com/ # no response data - - https://torrent9.proxyninja.org/ # Error 1007 - - https://www.torrent9.se/ - - https://torrent9.unblockninja.com/ # 403 forbidden - - https://ww1.torrent9.fm/ - - https://www.torrent9.zone/ # clone? details links are broken - - https://torrent9.to/ - - https://ww2.torrent9.to/ - -caps: - # dont forget to update the search fields category case block - categorymappings: - - {id: films, cat: Movies, desc: "Movies"} - - {id: series, cat: TV, desc: "TV"} - - {id: musique, cat: Audio, desc: "Music"} - - {id: ebook, cat: Books, desc: "Books"} - - {id: logiciels, cat: PC, desc: "Software"} - - {id: jeux-pc, cat: PC/Games, desc: "PC Games"} - - {id: other, cat: Other, desc: "Other"} # dummy cat for results missing icon - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: info_flaresolverr - type: info_flaresolverr - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - - name: sort - type: select - label: Sort requested from site (Only works for searches with Keywords) - default: ".html" - options: - ".html": best - ".html,trie-date-d": created desc - ".html,trie-date-a": created asc - ".html,trie-seeds-d": seeders desc - ".html,trie-seeds-a": seeders asc - ".html,trie-poid-d": size desc - ".html,trie-poid-a": size asc - ".html,trie-nom-d": title desc - ".html,trie-nom-a": title asc - -download: - selectors: - - selector: a[href^="magnet:?"] - attribute: href - - selector: script:contains("magnet:?") - filters: - - name: regexp - args: "\\s'(magnet:\\?.+?)';" - -search: - paths: - - path: "{{ if .Keywords }}search_torrent/{{ .Keywords }}{{ .Config.sort }}{{ else }}home/{{ end }}" - keywordsfilters: - # if searching for season packs with S01 to saison 1 #9712 - - name: re_replace - args: ["(?i)(S0)(\\d{1,2})$", "saison $2"] - - name: re_replace - args: ["(?i)(S)(\\d{1,3})$", "saison $2"] - - name: replace - args: [" ", "-"] - - headers: - # site blocks all Linux User-Agents, so use a slightly altered Windows Jackett UA here (e.g. Safari/537.36 > Safari/537.35) - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.35"] - - rows: - selector: table.table-striped > tbody > tr - filters: - - name: andmatch - - fields: - category_optional: - selector: td:nth-child(1) i - optional: true - case: - i[class="fa fa-video-camera"]: films - i[class="fa fa-tv"]: series # search by name - i[class="fa fa-desktop"]: series # keywordless search - i[class="fa fa-music"]: musique - i[class="fa fa-gamepad"]: jeux-pc - i[class="fa fa-laptop"]: logiciels - i[class="fa fa-book"]: ebook - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}other{{ end }}" - title_default: - selector: td:nth-child(1) a - title_optional: - selector: td:nth-child(1) a[title] - attribute: title - optional: true - title_phase1: - text: "{{ if .Result.title_optional }}{{ .Result.title_optional }}{{ else }}{{ .Result.title_default }}{{ end }}" - filters: - - name: re_replace - args: ["(?i)\\b(FRENCH|MULTI|TRUEFRENCH|VOSTFR|SUBFRENCH)\\b(.+?)(\\b((19|20)\\d{2})\\b)$", "$3 $1$2"] - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: td:nth-child(1) a - attribute: href - download: - selector: td:nth-child(1) a - attribute: href - date: - selector: td:nth-child(2):contains("/") - optional: true - default: now - filters: - - name: dateparse - args: "dd/MM/yyyy" - size: - selector: "{{ if .Keywords }}td:nth-child(3){{ else }}td:nth-child(2){{ end }}" - filters: - - name: re_replace - args: ["(\\w)o", "$1B"] - seeders: - selector: "{{ if .Keywords }}td:nth-child(4){{ else }}td:nth-child(3){{ end }}" - optional: true - default: 0 - leechers: - selector: "{{ if .Keywords }}td:nth-child(5){{ else }}td:nth-child(4){{ end }}" - optional: true - default: 0 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentbd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentbd.yml deleted file mode 100644 index c6feb8b8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentbd.yml +++ /dev/null @@ -1,181 +0,0 @@ ---- -id: torrentbd -name: TorrentBD -description: "TorrentBD is a BANGLADESH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.torrentbd.net/ - - https://www.torrentbd.org/ -legacylinks: - - http://www.torrentbd.com/torrent - - http://www.torrentbd.com/ - - https://www.torrentbd.com/ - -caps: - categorymappings: - # get the cats from https://www.torrentbd.net/feed.php?custom - - {id: 28, cat: TV/Anime, desc: "Anime - All"} - - {id: 65, cat: PC/Mobile-Android, desc: "Apps - Android"} - - {id: 18, cat: PC, desc: "Apps - PC"} - - {id: 19, cat: PC/Mac, desc: "Apps - Mac"} - - {id: 20, cat: PC, desc: "Apps - Linux"} - - {id: 49, cat: TV, desc: "Cartoons - All"} - - {id: 9, cat: TV/Documentary, desc: "Documentaries - All"} - - {id: 36, cat: Books/EBook, desc: "Other - E-Books"} - - {id: 73, cat: Books/Comics, desc: "E-Books - Comics"} - - {id: 77, cat: Books/EBook, desc: "E-Books - Manga"} - - {id: 10, cat: PC/Games, desc: "Games - PC"} - - {id: 60, cat: PC/Games, desc: "Games - Cracks | Patches"} - - {id: 52, cat: PC/Games, desc: "Games - Updates | DLC"} - - {id: 81, cat: PC/Games, desc: "Games - Backup"} - - {id: 43, cat: Console/PS3, desc: "Games - PlayStation"} - - {id: 14, cat: Console/XBox, desc: "Games - Xbox"} - - {id: 17, cat: Console, desc: "Games - Other"} - - {id: 86, cat: Movies/UHD, desc: "Movies - Blu-Ray Lossless 4K"} - - {id: 76, cat: Movies/BluRay, desc: "Movies - Blu-Ray Lossless 1080p"} - - {id: 80, cat: Movies/UHD, desc: "Movies - Blu-Ray 4K"} - - {id: 47, cat: Movies/HD, desc: "Movies - Blu-Ray 1080p"} - - {id: 42, cat: Movies/BluRay, desc: "Movies - Blu-Ray 720p"} - - {id: 24, cat: Movies/SD, desc: "Movies - Blu-Ray SD"} - - {id: 82, cat: Movies/WEB-DL, desc: "Movies - WEB-DL 4K"} - - {id: 55, cat: Movies/WEB-DL, desc: "Movies - WEB-DL"} - - {id: 83, cat: Movies/WEB-DL, desc: "Movies - WEBRip"} - - {id: 46, cat: Movies/HD, desc: "Movies - HD-Rip"} - - {id: 1, cat: Movies/SD, desc: "Movies - DVDRip"} - - {id: 4, cat: Movies/SD, desc: "Movies - CAM | TS | DVDScr | Pre-DVD"} - - {id: 67, cat: Movies/3D, desc: "Movies - 3D"} - - {id: 3, cat: Movies, desc: "Movies - Unrated"} - - {id: 2, cat: Movies, desc: "Movies - Packs"} - - {id: 22, cat: Audio, desc: "Music - Audio"} - - {id: 71, cat: Audio/Lossless, desc: "Music - Lossless"} - - {id: 25, cat: Audio/Video, desc: "Music - Video"} - - {id: 64, cat: Audio, desc: "Music - Concerts | Live Shows"} - - {id: 26, cat: Audio, desc: "Music - Radio"} - - {id: 38, cat: PC/Mobile-Other, desc: "Other - Mobile Phone"} - - {id: 44, cat: Other, desc: "Other - Religious"} - - {id: 40, cat: Other, desc: "Other - Miscellaneous"} - - {id: 70, cat: TV/Sport, desc: "Sports - Football"} - - {id: 6, cat: TV/Sport, desc: "Sports - Pro Wrestling"} - - {id: 48, cat: TV/Sport, desc: "Sports - All"} - - {id: 39, cat: Other, desc: "Tutorials - All"} - - {id: 84, cat: TV/UHD, desc: "TV - Episodes 4K"} - - {id: 61, cat: TV/HD, desc: "TV - Episodes 720p | 1080p"} - - {id: 5, cat: TV/SD, desc: "TV - Episodes SD"} - - {id: 85, cat: TV/UHD, desc: "TV - Packs 4K"} - - {id: 62, cat: TV/HD, desc: "TV - Packs - 720p | 1080p"} - - {id: 41, cat: TV/SD, desc: "TV - Packs SD"} - - {id: 66, cat: TV, desc: "TV - Awards | Ceremonies"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: button[href="account-logout.php"] - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - Referer: ["{{ .Config.sitelink }}index.php?initK&all"] - - paths: - - path: ajgettorrents.php - method: post - inputs: - page: 1 - origin: home - # sort is not available on first call, requires 2nd call to ajsearch.php - sortBy: "" - query: "{{ .Keywords }}" - fl: "{{ if .Config.freeleech }}true{{ else }}false{{ end }}" - # internal - intrls: false - # activeonly - active: false - spcat: "" - - rows: - selector: table.torrents-table > tbody > tr - - fields: - category: - selector: td[onclick^="mtt.getSpCatTorrents"] - attribute: onclick - filters: - - name: regexp - args: (\d+) - title: - selector: a[href^="torrents-details.php?id="] - details: - selector: a[href^="torrents-details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - date: - selector: td.torrent-name span:last-child - filters: - - name: replace - args: ["<", ""] - - name: replace - args: ["mo", " months"] - - name: replace - args: ["s", " seconds"] - - name: replace - args: ["m", " minutes"] - - name: replace - args: ["h", " hours"] - - name: replace - args: ["d", " days"] - - name: replace - args: ["w", "weeks"] - - name: replace - args: ["y", "years"] - - name: timeago - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - downloadvolumefactor: - case: - img[src="images/free.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.5 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentby.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentby.yml deleted file mode 100644 index 8290a0ec..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentby.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -id: torrentby -name: torrent.by -description: "torrent.by is a BELARUSIAN Public Torrent Tracker" -language: ru-RU -type: public -encoding: UTF-8 -requestDelay: 5 -certificates: - - 86d0328f79c36c9f716c8fe6df8a06351ab6e20c # Expired 14th March 2026 -links: - - https://torrent.by/ -legacylinks: - - http://torrent.by/ - -caps: - categories: - Other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: info_category_8000 - type: info_category_8000 - -search: - paths: - - path: "{{ if .Keywords }}search/?search={{ .Keywords }}&category=0{{ else }}today/{{ end }}" - - keywordsfilters: - # strip season and/or ep - - name: re_replace - args: ["(?i)\\b(?:[SE]\\d{1,4}){1,2}\\b\\s?", ""] - - rows: - selector: tr[class^="ttable_col"] - - fields: - category: - text: other - title: - selector: td:nth-child(3) a - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)[xх](\\d+(?:-\\d+)?)\\s*из\\s*(\\d+)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)[xх](\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\d+)", "E$1 of $2"] - - name: re_replace - args: ["(?i)\\bFiles-х\\b", "Files-x"] - - name: re_replace - args: ["(?i)\\sот\\s([\\w\\p{P}\\p{S}]+)$", "-$1"] - - name: re_replace - args: ["\\s\\|\\s(\\w{4,})$", "-$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: td:nth-child(3) a - attribute: href - download: - selector: a.dwnld - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-child(5) - seeders: - selector: font[color="green"] - leechers: - selector: font[color="red"] - date_day: - selector: td:nth-child(1):contains("Вчера"), td:nth-child(1):contains("Сегодня") - optional: true - filters: - - name: replace - args: ["Вчера", "Yesterday"] - - name: replace - args: ["Сегодня", "Today"] - - name: fuzzytime - date_year: - selector: td:nth-child(1):contains("-") - optional: true - filters: - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "yyyy-MM-dd zzz" - date: - text: "{{ if or .Result.date_year .Result.date_day }}{{ or .Result.date_year .Result.date_day }}{{ else }}now{{ end }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: td:nth-child(3) a -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentccf.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentccf.yml deleted file mode 100644 index 3aaf6011..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentccf.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: torrentccf -name: TorrentCCF -description: "TorrentCCF (TCCF) is a CHINESE Private Torrent Tracker for HD MOVIES / TV" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://et8.org/ -legacylinks: - - http://et8.org/ - -caps: - categorymappings: - - {id: 622, cat: Movies, desc: "Movies.电影"} - - {id: 623, cat: TV, desc: "TV.电视剧"} - - {id: 624, cat: TV/Documentary, desc: "Documentaries.纪录片"} - - {id: 625, cat: PC, desc: "Appz.软件"} - - {id: 626, cat: Audio, desc: "Music & MusicVideos.音乐及MV"} - - {id: 627, cat: Other, desc: "Others.其他(非学习类)"} - - {id: 628, cat: Books/Other, desc: "Elearning - 杂项学习"} - - {id: 629, cat: Books/EBook, desc: "Elearning - 电子书/小说"} - - {id: 630, cat: Books/EBook, desc: "Elearning - 电子书/非小说"} - - {id: 631, cat: Books/Mags, desc: "Elearning - 杂志"} - - {id: 632, cat: Books/Comics, desc: "Elearning - 漫画"} - - {id: 633, cat: Audio/Audiobook, desc: "Elearning - 有声书"} - - {id: 634, cat: Books, desc: "Elearning - 公开课"} - - {id: 635, cat: Books, desc: "Elearning - 视频教程"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted. updated @ 2020.03.19
  2. Insane User or above would never be deleted if parked (at User CP)
  3. Parked accounts would be disabled if users have not logged in for more than 365 days in a row. updated @ 2020.09.20
  4. Un-parked accounts would be disabled if users have not logged in for more than 90 days in a row. updated @ 2020.09.20
  5. Accounts with both uploaded and downloaded amount being 0 would be disabled if users have not logged in for more than 30 days in a row.
" - -login: - # switched from the form method to the cookie method due to #9577 - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - method: get - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - # currently supports only one query id at one time. - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - selector: a[href^="details.php?id="][onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: tt(\d+) - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(6) - leechers: - selector: td:nth-child(7) - date_elapsed: - # time type: time elapsed (default) - selector: td:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.8 - description: - selector: td:nth-child(2) - remove: a, img -# NexusPHP Standard v1.5 Beta 3 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentcore.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentcore.yml deleted file mode 100644 index 0aff6e3b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentcore.yml +++ /dev/null @@ -1,121 +0,0 @@ ---- -id: torrentcore -name: Torrent[CORE] -description: "Torrent[CORE] is a Public DHT Crawler" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 10.1 -links: - - https://torrentcore.xyz/ - -caps: - categorymappings: - - {id: 0DAY, cat: PC/0day, desc: Software 0DAY} - - {id: DVDR, cat: Movies/DVD, desc: Movies DVDR} - - {id: EBOOK, cat: Books, desc: Ebook} - - {id: FLAC, cat: Audio/Lossless, desc: Music FLAC} - - {id: GAMES, cat: PC/Games, desc: Games} - - {id: MP3, cat: Audio/MP3, desc: Music MP3} - - {id: MP4, cat: Movies, desc: Movies MP4} - - {id: PDA, cat: PC/Mobile-Other, desc: Software PDA} - - {id: PS3, cat: Console/PS3, desc: Games PS3} - - {id: TV, cat: TV, desc: TV} - - {id: TV-DVDRIP, cat: TV/SD, desc: TV DVDRIP} - - {id: TV-X265, cat: TV/HD, desc: TV X265} - - {id: TV-XVID, cat: TV/SD, desc: TV XVID} - - {id: UNKNOWN, cat: Other, desc: Unknown} - - {id: X264, cat: Movies/HD, desc: Movies X264} - - {id: XBOX360, cat: Console/XBox 360, desc: Games XBOX360} - - {id: XVID, cat: Movies/SD, desc: Movies XVID} - - {id: XXX, cat: XXX, desc: Adult} - - {id: XXX-0DAY, cat: XXX, desc: Adult 0DAY} - - {id: XXX-IMGSET, cat: XXX/ImageSet, desc: Adult IMGSET} - - {id: XXX-X264, cat: XXX, desc: Adult X264} - - {id: XXX-X265, cat: XXX, desc: Adult X265} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - infohash: - hash: - selector: div.content - filters: - - name: regexp - args: "Hash: ([A-F|a-f|0-9]{40})" - title: - selector: font[color="orange"] - filters: - - name: trim - - name: validfilename - -search: - paths: - - path: "{{ if .Keywords }}search?s={{ .Keywords }}{{ else }}index{{ end }}" - # search is an exact match, there are no wild cards, or +word or AND/OR, and spaces and dots are treated uniquely - error: - - selector: :root:contains("Service Unavailable") - - selector: :root:contains("Connection failed") - message: - text: "Connection failed: Connection refused" - - selector: :root:contains("[Anti-DDoS]") - - rows: - selector: div.content font[color="purple"], div.content font[color="orange"]:has(a), div.content font[color="gray"], div.content font[color="white"] - after: 3 - - fields: - category_optional: - selector: img[src^="./cat/"] - attribute: src - optional: true - filters: - - name: regexp - args: "cat/(.+?).png" - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}UNKNOWN{{ end }}" - title: - selector: a - details: - selector: a - attribute: href - download: - selector: a - attribute: href - size: - selector: font[color="purple"] - filters: - - name: regexp - args: "Size: (.+?)/" - files: - selector: font[color="purple"] - filters: - - name: regexp - args: "File: (\\d+)" - date: - selector: font[color="purple"] - filters: - - name: regexp - args: "\\[ (\\d{2} [A-Za-z]+ \\d{4} \\d{2}:\\d{2}:\\d{2}) \\]" - - name: append - args: " -00:00" # GMT - - name: dateparse - args: "dd MMMM yyyy HH:mm:ss zzz" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdd.yml deleted file mode 100644 index 00d6a8a0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdd.yml +++ /dev/null @@ -1,174 +0,0 @@ ---- -id: torrentdd -name: TorrentDD -description: "TorrentDD (TodayBit) is a THAI Private Torrent Tracker for MOVIES / TV / GENERAL" -language: th-TH -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://www.torrentdd.com/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 1, cat: Other, desc: "พระมหากษัตริย์", default: true} - - {id: 2, cat: TV/Anime, desc: "การ์ตูน/อนิเมชั่น", default: true} - - {id: 3, cat: Console, desc: "Game/PSP/ND/Mobile", default: true} - - {id: 4, cat: PC/Games, desc: "Game/Pc/Ps/Xbox", default: true} - - {id: 5, cat: Books/Other, desc: "สื่อเรียนรู้/แม่และเด็ก", default: true} - - {id: 6, cat: Audio/Video, desc: "MV/karaoke", default: true} - - {id: 7, cat: Audio, desc: "เพลง", default: true} - - {id: 8, cat: PC/0day, desc: "OS Windows/Office", default: true} - - {id: 9, cat: PC/0day, desc: "AntiVirus/Antispyware", default: true} - - {id: 10, cat: PC/Mobile-Android, desc: "ซอฟแวร์ มือถือ/ธีม", default: true} - - {id: 11, cat: PC/Mac, desc: "ซอฟแวร์ แม็ค/ลินุกซ์", default: true} - - {id: 12, cat: PC/0day, desc: "ซอฟแวร์ทั่วไป", default: true} - - {id: 13, cat: Audio/Video, desc: "คอนเสิร์ต", default: true} - - {id: 14, cat: Audio/Other, desc: "ทอล์คโชว์/ตลก/วิทยุ", default: true} - - {id: 15, cat: Other/Misc, desc: "ทั่วไป", default: true} - - {id: 16, cat: Books/Other, desc: "ธรรมะ", default: true} - - {id: 17, cat: Other, desc: "font/icon/source", default: true} - - {id: 18, cat: Movies/UHD, desc: "ภาพยนตร์ 4K UHD", default: true} - - {id: 19, cat: Movies/HD, desc: "Hi-Def/Blu Ray", default: true} - - {id: 20, cat: Movies/SD, desc: "ภาพยนตร์ DVD", default: true} - - {id: 21, cat: Movies/SD, desc: "ภาพยนตร์ VCD", default: true} - - {id: 22, cat: Movies/SD, desc: "XviD/DivX/AVI/RM", default: true} - - {id: 23, cat: TV, desc: "รายการทีวี/วาไรตี้", default: true} - - {id: 24, cat: TV/Sport, desc: "กีฬา/ฟุตบอล", default: true} - - {id: 25, cat: Other, desc: "รูปภาพ/วอลเปเปอร์", default: true} - - {id: 26, cat: TV/Documentary, desc: "สารคดี", default: true} - - {id: 27, cat: Books/EBook, desc: "สื่อเรียนรู้/หนังสือ/Ebook", default: true} - - {id: 28, cat: TV/Foreign, desc: "หนังชุด/ซีรีส์ [เกาหลี]", default: true} - - {id: 29, cat: TV/Foreign, desc: "หนังชุด/ซีรีส์ [ญี่ปุ่น]", default: true} - - {id: 30, cat: TV/Foreign, desc: "หนังชุด/ซีรีส์ [จีน]", default: true} - - {id: 31, cat: TV/Foreign, desc: "หนังชุด/ซีรีส์ [ฝรั่ง]", default: true} - - {id: 32, cat: TV, desc: "หนังชุด/ซีรีส์ [ไทย]", default: true} - - {id: 33, cat: TV, desc: "คลิปทั่วไป", default: true} - - {id: 34, cat: XXX, desc: "XXX (uncensor)", default: false} - - {id: 35, cat: XXX, desc: "XXX (censor)", default: false} - - {id: 36, cat: XXX, desc: "XXX Clip", default: false} - - {id: 37, cat: XXX, desc: "XXX cartoon/book/pic", default: false} - - {id: 38, cat: XXX, desc: "XXX Gay/Lesbian", default: false} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.alert-warning:contains("Login failed!") - test: - path: home.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: button.border-success - attribute: onclick - filters: - - name: regexp - args: \'(.+?)\' - -search: - paths: - # https://www.torrentdd.com/browse.php?search=&sort=size&cate=1,4&freeload=yes&odb=DESC - - path: browse.php - categories: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33] - - path: browse18.php - categories: [34, 35, 36, 37, 38] - inputs: - cate: "{{ join .Categories \",\" }}" - freeload: "{{ if .Config.freeleech }}yes{{ else }}{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - odb: "{{ .Config.type }}" - # site does not support imdbid searching and does not display imdb links in results. - - rows: - selector: div.mt-3 table tbody tr:not(:has(label.badge-outline-danger)) - - fields: - category: - selector: a[href^="?cate="] - attribute: href - filters: - - name: querystring - args: cate - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - poster: - selector: a.box-poster - attribute: href - filters: - - name: regexp - args: '(.+?\.(jpg|jpeg|bmp|png)$)' - files: - selector: td:nth-last-child(7) - date: - selector: div.mt-2 span:first-child - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - label.badge-outline-success: 0 - "*": 1 - uploadvolumefactor: - case: - label.badge-outline-warning: 2 - "*": 1 - minimumratio: - text: 1.0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdownload.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdownload.yml deleted file mode 100644 index 2921de06..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdownload.yml +++ /dev/null @@ -1,142 +0,0 @@ ---- -id: torrentdownload -name: TorrentDownload -description: "TorrentDownload is a Public torrent meta-search engine" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.torrentdownload.info/ -legacylinks: - - https://torrentdownload.mrunblock.bond/ - - https://torrentdownload.nocensor.cloud/ - - https://torrentdownload.unblockit.download/ - -caps: - categorymappings: - - {id: Adult, cat: XXX, desc: Adult} - - {id: AdultPornHDVideo, cat: XXX, desc: "Adult Porn HD Video"} - - {id: AdultPornPictures, cat: XXX, desc: "Adult Porn Pictures"} - - {id: AdultPornVideo, cat: XXX, desc: "Adult Porn Video"} - - {id: Anime, cat: TV/Anime, desc: Anime} - - {id: AnimeEnglishtranslated, cat: TV/Anime, desc: "Anime English translated"} - - {id: AnimeAnimeOther, cat: TV/Anime, desc: "Anime Other"} - - {id: Applications, cat: PC/0day, desc: Applications} - - {id: ApplicationsAndroid, cat: PC/Mobile-Android, desc: "Applications Android"} - - {id: ApplicationsWindows, cat: PC/0day, desc: "Applications Windows"} - - {id: AudioBooks, cat: Audio/Audiobook, desc: "Books Audiobooks"} - - {id: AudioAudiobooks, cat: Audio/Audiobook, desc: "Books Audiobooks"} - - {id: AudioLossless, cat: Audio/Lossless, desc: "Audio Lossless"} - - {id: AudioMusic, cat: Audio/MP3, desc: "Audio Music"} - - {id: BooksAcademic, cat: Books, desc: "Books Academic"} - - {id: BooksComics, cat: Books/Comics, desc: "Books Comics"} - - {id: BooksEbooks, cat: Books/EBook, desc: "Books Ebooks"} - - {id: BooksEducational, cat: Books, desc: "Books Educational"} - - {id: BooksMagazines, cat: Books/Mags, desc: "Books Magazines"} - - {id: BooksFiction, cat: Books, desc: "Books Fiction"} - - {id: BooksNonfiction, cat: Books, desc: "Books Nonfiction"} - - {id: BooksTextbooks, cat: Books, desc: "Books Textbooks"} - - {id: Ebooks, cat: Books/EBook, desc: "Books Ebooks"} - - {id: Games, cat: Console, desc: Games} - - {id: GamesWindows, cat: PC/Games, desc: "Games Windows"} - - {id: Movies, cat: Movies, desc: Movies} - - {id: MoviesAction, cat: Movies, desc: Movies Action} - - {id: MoviesConcerts, cat: Movies, desc: "Movies Concerts"} - - {id: MoviesCrime, cat: Movies, desc: "Movies Crime"} - - {id: MoviesDocumentary, cat: TV/Documentary, desc: "Movies Documentary"} - - {id: MoviesDubbedMovies, cat: Movies, desc: "Movies Dubbed Movies"} - - {id: MoviesHighresMovies, cat: Movies, desc: "Movies Highres Movies"} - - {id: MoviesMusicvideos, cat: Audio/Video, desc: "Movies Musicvideos"} - - {id: MoviesThriller, cat: Movies, desc: "Movies Thriller"} - - {id: Music, cat: Audio, desc: Music} - - {id: MusicHardrock, cat: Audio, desc: "Music Hardrock"} - - {id: MusicMp, cat: Audio/MP3, desc: "Music Mp3"} - - {id: MusicFLAC, cat: Audio/Lossless, desc: "Music FLAC"} - - {id: MusicLossless, cat: Audio/Lossless, desc: "Music Lossless"} - - {id: MusicRB, cat: Audio, desc: "Music R&B"} - - {id: MusicTranceHouseDance, cat: Audio, desc: "Music Trance House Dance"} - - {id: Other, cat: Other, desc: Other} - - {id: OtherEbooks, cat: Books/EBook, desc: "Other Ebooks"} - - {id: OtherComics, cat: Other, desc: "Other Comics"} - - {id: OtherTutorials, cat: Other, desc: "Other Tutorials"} - - {id: OtherUnsorted, cat: Other, desc: "Other Unsorted"} - - {id: PicturesPicturesOther, cat: Other/Misc, desc: "Pictures Other"} - - {id: PicturesWallpapers, cat: Other/Misc, desc: "Pictures Wallpapers"} - - {id: Software, cat: PC/0day, desc: "Software"} - - {id: TV, cat: TV, desc: TV} - - {id: TVBBC, cat: TV, desc: "TV BBC"} - - {id: TVEllenDeGeneres, cat: TV, desc: "TV Ellen DeGeneres"} - - {id: TVOther, cat: TV/Other, desc: "TV Other"} - - {id: TVshows, cat: TV, desc: "TV shows"} - - {id: Television, cat: TV, desc: Television} - - {id: VideoMobile, cat: Movies, desc: "Movies Video Mobile"} - - {id: VideoMovies, cat: Movies, desc: "Video Movies"} - - {id: VideoMusic, cat: Audio/Video, desc: "Video Music"} - - {id: XXX, cat: XXX, desc: XXX} - - {id: XXXVideo, cat: XXX, desc: "XXX Video"} - - {id: XXXHDVideo, cat: XXX, desc: "XXX HD Video"} - - {id: XXXPictures, cat: XXX, desc: "XXX Pictures"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site (Applies only to Search with Keywords) - default: d - options: - d: created - _: seeders - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}search{{ re_replace .Config.sort \"_\" \"\" }}?q={{ .Keywords }}{{ else }}/{{ end }}" - - rows: - selector: table.table2 > tbody > tr:has(span.smallish) - - fields: - category: - selector: div.tt-name > span.smallish - filters: - - name: re_replace - args: ["[^A-Za-z]+", ""] # strip everything but letters - title: - selector: div.tt-name > a[href^="/"] - details: - selector: div.tt-name > a[href^="/"] - attribute: href - download: - selector: div.tt-name > a[href^="/"] - attribute: href - date: - selector: td:nth-child(2) - filters: - - name: replace - args: ["right ", ""] - - name: replace - args: ["Last Month", "1 month ago"] - - name: replace - args: ["+", " ago"] - - name: timeago - size: - selector: td:nth-child(3) - seeders: - selector: td.tdseed - leechers: - selector: td.tdleech - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdownloads.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdownloads.yml deleted file mode 100644 index 1b75dadb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentdownloads.yml +++ /dev/null @@ -1,116 +0,0 @@ ---- -id: torrentdownloads -name: Torrent Downloads -description: "Torrent Downloads (TD) is a Public torrent site for all kinds of content" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.torrentdownloads.pro/ - - https://torrentdownloads.unblockninja.com/ - - https://torrentdownloads.ninjaproxy1.com/ - - https://torrentdownloads.proxyninja.org/ - - https://torrentdownloads.proxyninja.net/ -legacylinks: - - https://www.torrentdownloads.me/ - - https://www.torrentdownloads.info/ - - https://torrentdownloads.mrunblock.bond/ - - https://torrentdownloads.nocensor.cloud/ - - https://torrentdownloads.unblockit.download/ - -caps: - categorymappings: - - {id: 8, cat: TV, desc: "TV Shows"} - - {id: 4, cat: Movies, desc: "Movies"} - - {id: 5, cat: Audio, desc: "Music"} - - {id: 3, cat: PC/Games, desc: "Games"} - - {id: 7, cat: PC, desc: "Software"} - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 2, cat: Books, desc: "Books"} - - {id: 9, cat: Other, desc: "Other"} - - {id: 0, cat: Other, desc: "Other"} - - {id: 6, cat: Other, desc: "Pics"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: downloadlink - type: select - label: Download link - default: "magnet:" - options: - "http://itorrents.org/": "iTorrents.org" - "magnet:": "magnet" - - name: downloadlink2 - type: select - label: Download link (fallback) - default: "http://itorrents.org/" - options: - "http://itorrents.org/": "iTorrents.org" - "magnet:": "magnet" - - name: info_download - type: info - label: About the Download links - default: As the .torrent download links on this site are known to fail from time to time, you can optionally set as a fallback an automatic alternate link. - -download: - selectors: - - selector: a[href^="{{ .Config.downloadlink }}"] - attribute: href - - selector: a[href^="{{ .Config.downloadlink2 }}"] - attribute: href - -search: - paths: - - path: search/ - followredirect: true - inputs: - $raw: "{{ range .Categories }}s_cat={{.}}&{{end}}" - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - - keywordsfilters: - - name: re_replace - args: ["\\W+", " "] - - name: trim - - rows: - selector: div.inner_container > div:has(p:has(a[href^="/torrent/"])):not(:has(span.__cf_email__)) - - fields: - category: - selector: img[src^="/templates/new/images/icons/menu_icon"] - attribute: src - filters: - - name: regexp - args: (\d+) - title: - selector: p:nth-child(1) > a - attribute: title - filters: - - name: replace - args: ["View torrent info : ", ""] - details: - selector: p:nth-child(1) > a[href^="/torrent/"] - attribute: href - download: - selector: p:nth-child(1) > a[href^="/torrent/"] - attribute: href - date: - text: now - size: - selector: span:nth-child(5) - seeders: - selector: span:nth-child(4) - leechers: - selector: span:nth-child(3) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenteros-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenteros-api.yml deleted file mode 100644 index c063edaa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenteros-api.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: torrenteros-api -name: Torrenteros (API) -description: "Torrenteros (TTR) is a SPANISH Torrent Tracker for MOVIES / TV / GENERAL" -language: es-ES -type: private -encoding: UTF-8 -links: - - https://torrenteros.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your TTR account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Ciencia ficción)", "Ciencia_ficción"] - - name: re_replace - args: ["(?i)(Película de TV)", "Película_de_TV"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " -03:00" # BRT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.25 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.25 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.2.0 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentgalaxyclone.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentgalaxyclone.yml deleted file mode 100644 index 3d06050b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentgalaxyclone.yml +++ /dev/null @@ -1,96 +0,0 @@ ---- -id: torrentgalaxyclone -name: TorrentGalaxyClone -description: "TorrentGalaxyClone is a Public site for MOVIES / TV / GENERAL" -language: en-US -type: public -encoding: UTF-8 -# https://proxygalaxy.cc/ for health status and alternate domains -links: - - https://torrentgalaxy.one/ - - https://torrentgalaxy.info/ - - https://torrentgalaxy.space/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: Anime, cat: TV/Anime, desc: "Anime"} - - {id: Apps, cat: PC, desc: "Apps"} - - {id: Books, cat: Books, desc: "Books"} - - {id: Docus, cat: TV/Documentary, desc: "Documentaries"} - - {id: Games, cat: Console, desc: "Games"} - - {id: Movies, cat: Movies, desc: "Movies"} - - {id: Music, cat: Audio, desc: "Music"} - - {id: Other, cat: Other, desc: "Other"} - - {id: TV, cat: TV, desc: "TV"} - - {id: XXX, cat: XXX, desc: "XXX"} - # unlisted - - {id: Documentaries, cat: TV/Documentary, desc: "Docus"} - - {id: E-books, cat: Books, desc: "E-books"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: uploader - type: text - label: Filter by Uploader - - name: info_uploader - type: info - label: About filtering by Uploader - default: "You can filter by Uploader by entering a Case Sensitive username, or leave empty to get all results.
Note: this is the username of the Uploader and not the Groupname that often show up at the end of TGx titles, eg RMTeam." - -download: - selectors: - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - # https://torrentgalaxy.one/get-posts/keywords:tt1890725/ - # https://torrentgalaxy.one/get-posts/keywords:andor/ - # https://torrentgalaxy.one/get-posts/category:Movies:category:TV:keywords:bodies - paths: - - path: "get-posts/{{ if or .Query.IMDBID .Keywords }}keywords:{{ or .Query.IMDBID .Keywords }}{{ else }}{{ end }}{{ range .Categories }}:category:{{.}}{{end}}" - - rows: - selector: "div.tgxtablerow{{ if .Config.uploader }}:has(a.username:contains({{ .Config.uploader }})){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="/get-posts/category:"] - title: - selector: a[href^="/post-detail/"] - attribute: title - details: - selector: a[href^="/post-detail/"] - attribute: href - download: - selector: a[href^="/post-detail/"] - attribute: href - imdbid: - selector: a[href^="/get-posts/keywords:tt"] - attribute: href - size: - selector: div.tgxtablecell:nth-last-child(5) - seeders: - selector: div.tgxtablecell:nth-last-child(2) span font - leechers: - selector: div.tgxtablecell:nth-last-child(2) span font:nth-of-type(2) - date: - selector: div.tgxtablecell:nth-last-child(1) - remove: div.bighide - filters: - - name: timeago - _username: - selector: a.username - description: - text: "Uploader: {{ .Result._username }}" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenthr.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenthr.yml deleted file mode 100644 index da158323..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenthr.yml +++ /dev/null @@ -1,163 +0,0 @@ ---- -id: torrenthr -name: TorrentHR -description: "TorrentHR is a ratioless CROATIAN Private Torrent Tracker for 0DAY / GENERAL" -language: hr-HR -type: private -encoding: windows-1250 -links: - - https://www.torrenthr.org/ - -caps: - categorymappings: - - {id: 31, cat: TV/Anime, desc: "Anime"} - - {id: 4, cat: Movies/SD, desc: "Filmovi/SD"} - - {id: 18, cat: Movies/Foreign, desc: "Crtani Filmovi"} - - {id: 5, cat: PC/Games, desc: "Igre/PC"} - - {id: 7, cat: TV/SD, desc: "Serije/SD"} - - {id: 1, cat: PC/0day, desc: "Aplikacije"} - - {id: 14, cat: Movies/DVD, desc: "Filmovi/DVD"} - - {id: 12, cat: TV/Documentary, desc: "Dokumentarni Filmovi"} - - {id: 27, cat: Console/PS4, desc: "Igre/PS"} - - {id: 34, cat: TV/HD, desc: "Serije/HD"} - - {id: 25, cat: Books, desc: "E-books"} - - {id: 17, cat: Movies/HD, desc: "Filmovi/HD"} - - {id: 11, cat: Audio, desc: "Koncerti/Spotovi"} - - {id: 28, cat: Console/Wii, desc: "Igre/Wii"} - - {id: 30, cat: Books/Comics, desc: "Stripovi"} - - {id: 38, cat: PC/Mobile-Other, desc: "Smartphone"} - - {id: 40, cat: Movies/BluRay, desc: "Filmovi/BD"} - - {id: 3, cat: Audio/MP3, desc: "Glazba/MP3"} - - {id: 26, cat: Console/XBox, desc: "Igre/Xbox"} - - {id: 10, cat: XXX, desc: "XXX/SD"} - - {id: 16, cat: TV, desc: "THR Packs"} - - {id: 15, cat: TV/Sport, desc: "Sport"} - - {id: 29, cat: Audio/Lossless, desc: "Glazba/FLAC"} - - {id: 13, cat: Other, desc: "Ostalo"} - - {id: 36, cat: XXX, desc: "XXX/HD"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - ssl: yes - error: - - selector: div.glavni:has(div.glavni_naslov:contains("Greška")) - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 name, 1 descr, 2 both - blah: "{{ if .Query.IMDBID }}2{{ else }}0{{ end }}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: div.glavni_txt table > tbody > tr[id^="record-"]:not(:has(img[src="/pic/nuked.gif"])) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title_optional: - optional: true - selector: a[href^="details.php?id="][title] - attribute: title - title_optional2: - optional: true - selector: a[href^="details.php?id="][onmousemove] - attribute: onmousemove - filters: - - name: regexp - args: "return overlibImage\\('(.*)','.*'\\);" - title: - text: "{{ if or .Result.title_optional .Result.title_optional2 }}{{ or .Result.title_optional .Result.title_optional2 }}{{ else }}{{ .Result.title_default }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - files: - selector: td:nth-child(5) - size: - selector: td:nth-child(7) - grabs: - selector: td:nth-child(8) - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - poster: - selector: a[href^="details.php?id="][onmousemove] - attribute: onmousemove - filters: - - name: regexp - args: "return overlibImage\\('.*','(.*)'\\);" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - date: - selector: td:nth-child(2) > small - # auto adjusted by site account profile - filters: - - name: replace - args: [" u ", " "] - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss" - description: - selector: td:nth-child(2) - remove: a, br, small - minimumratio: - text: 1.0 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenting.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenting.yml deleted file mode 100644 index 957141f7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenting.yml +++ /dev/null @@ -1,128 +0,0 @@ ---- -id: torrenting -name: Torrenting -description: "Torrenting (TT) is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: windows-1252 -links: - - https://torrenting.com/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: XXX, desc: "XXX"} - - {id: 3, cat: Movies/BluRay, desc: "Movies/BluRay"} - - {id: 4, cat: TV/SD, desc: "TV/SD-x264"} - - {id: 5, cat: TV/HD, desc: "TV/HD-x264"} - - {id: 11, cat: Movies/HD, desc: "Movies/HD"} - - {id: 18, cat: TV, desc: "TV/Packs"} - - {id: 21, cat: Console, desc: "Games/Consoles"} - - {id: 26, cat: Audio/Video, desc: "Music/Videos"} - - {id: 27, cat: Audio, desc: "Music/Audio"} - - {id: 29, cat: TV/Anime, desc: "Anime/Toons"} - - {id: 30, cat: Books, desc: "Books"} - - {id: 34, cat: PC/0day, desc: "Applications"} - - {id: 35, cat: PC/Games, desc: "Games/PC"} - - {id: 38, cat: Movies/Foreign, desc: "Movies/Non-English"} - - {id: 40, cat: Movies/DVD, desc: "Movies/DVD-R"} - - {id: 47, cat: Movies, desc: "Movies/Packs"} - - {id: 49, cat: Movies/HD, desc: "Movies/x265"} - - {id: 55, cat: TV/Sport, desc: "Sports"} - - {id: 82, cat: TV/Foreign, desc: "TV/Non-English"} - - {id: 93, cat: Audio, desc: "Music/Packs"} - - {id: 99, cat: TV/HD, desc: "TV/x265"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_activity - type: info - label: Account Inactivity - default: "All members are required to log into their account at least every 89 days or you will be deleted without warning." - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: t - selector: a[href="/logout.php"] - -search: - paths: - # https://torrenting.com/t?1=&5=&free=on&q=climax&qf=ti - - path: t - inputs: - $raw: "{{ range .Categories }}{{.}}=&{{end}}" - q: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }} {{ else }}{{ end }}{{ .Keywords }}" - qf: "{{ if .Query.IMDBID }}adv{{ else }}ti{{ end }}" - free: "{{ if .Config.freeleech }}on{{ else }}{{ end }}" - - rows: - selector: table#torrentsTable > tbody > tr:has(td.torrentNameInfo) - - fields: - category: - selector: a[href^="?"] - attribute: href - filters: - - name: replace - args: ["?", ""] - title: - selector: a[href^="/torrent.php?id="] - details: - selector: a[href^="/torrent.php?id="] - attribute: href - download: - selector: a[href^="/download.php/"] - attribute: href - genre: - selector: td.torrentNameInfo > div - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: validate - args: "Action, Adventure, Animation, Biography, Comedy, Crime, Documentary, Drama, Family, Fantasy, Game-Show, History, Horror, Music, Musical, Mystery, News, Reality-TV, Romance, Science_Fiction, Sci-Fi, Short, Sitcom, Sport, Talk-Show, Thriller, War, Western" - description: - text: "{{ .Result.genre }}" - date: - selector: td.torrentNameInfo > div - filters: - - name: re_replace - args: [" by.*", ""] - - name: split - args: ["|", -1] - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:nth-last-child(1) - size: - selector: td:nth-last-child(3) - downloadvolumefactor: - case: - "span:contains(\"FreeLeech\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.1 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentkitty.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentkitty.yml deleted file mode 100644 index 275d1ce3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentkitty.yml +++ /dev/null @@ -1,72 +0,0 @@ ---- -id: torrentkitty -name: TorrentKitty -description: "TorrentKitty is a Public torrent indexer" -language: en-US -type: public -encoding: UTF-8 -links: - - https://www.torrentkitty.cam/ - - https://www.torrentkitty.ink/ - - https://www.torrentkitty.io/ - - https://www.torrentkitty.vip/ - - https://www.torrentkitty.app/ - - https://www.torrentkitty.red/ - - https://www.torrentkitty.lol/ - - https://www.torrentkitty.best/ - - https://www.torrentkitty.tv/ - - https://torkitty.com/ -legacylinks: - - https://www.torrentkitty.se/ - -caps: - categories: - Other: Other - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - - name: info_category_8000 - type: info_category_8000 - -search: - paths: - - path: "search/{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}/" - - rows: - selector: table#archiveResult tbody tr:has(a[href^="magnet:?xt="]) - - fields: - category: - text: Other - title: - selector: td.name - details: - selector: td.action a - attribute: href - download: - selector: a[href^="magnet:?xt="] - attribute: href - date: - selector: td.date - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-dd zzz" - size: - selector: td.size - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentleech-pl.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentleech-pl.yml deleted file mode 100644 index 24b72279..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentleech-pl.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: torrentleech-pl -name: Torrentleech.pl -description: "Torrentleech.pl is a POLISH Private Torrent Tracker for 0DAY / GENERAL" -language: pl-PL -type: private -encoding: UTF-8 -links: - - https://torrentleech.pl/ - -caps: - categorymappings: - - {id: 32, cat: PC, desc: "Aplikacje PC"} - - {id: 33, cat: Books, desc: "eBooki/AudioBooki/Prasa"} - - {id: 65, cat: Movies/3D, desc: "Filmy/3D"} - - {id: 69, cat: Movies/UHD, desc: "Filmy/4K UHD"} - - {id: 28, cat: TV/Anime, desc: "Filmy/Animowane"} - - {id: 66, cat: Movies/BluRay, desc: "Filmy/BDRip"} - - {id: 61, cat: Movies/BluRay, desc: "Filmy/BRRip"} - - {id: 67, cat: Movies, desc: "Filmy/CAM-TC-TS"} - - {id: 58, cat: TV/Documentary, desc: "Filmy/Dokumentalne"} - - {id: 39, cat: Movies/HD, desc: "Filmy/Full HD"} - - {id: 36, cat: Movies/HD, desc: "Filmy/HD Ready"} - - {id: 64, cat: Movies, desc: "Filmy/Kino Polskie"} - - {id: 59, cat: Movies, desc: "Filmy/PACK"} - - {id: 40, cat: Movies/SD, desc: "Filmy/SD"} - - {id: 68, cat: Movies/WEB-DL, desc: "Filmy/WEB-DL"} - - {id: 62, cat: Console, desc: "Gry/Dodatki"} - - {id: 41, cat: Console, desc: "Gry/Konsole"} - - {id: 42, cat: PC/Games, desc: "Gry/PC"} - - {id: 44, cat: PC/Mobile-Other, desc: "GSM/PDA"} - - {id: 57, cat: Audio/Video, desc: "Koncerty/Teledyski"} - - {id: 63, cat: Audio/Lossless, desc: "Muzyka/FLAC"} - - {id: 46, cat: Audio/MP3, desc: "Muzyka/MP3"} - - {id: 47, cat: Other, desc: "Różne"} - - {id: 60, cat: TV/Sport, desc: "Sport"} - - {id: 53, cat: TV, desc: "TV Seriale"} - - {id: 56, cat: XXX, desc: "XXX"} - - {id: 70, cat: TV/Anime, desc: "Anime"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: drop_polish_prefix - type: checkbox - label: Drop the Polish title prefix - default: false - - name: info_flaresolverr - type: info_flaresolverr - - name: info_activity - type: info - label: Account Inactivity - default: "Users of the Power User class and below who have been inactive for more than 62 days are deleted. Therefore, if for some reason you can't enter TorrentLeech.pl for a while, Park your account. You won't be able to download torrents, but you can be sure that you won't have to log in for 122 days. However, if you do not log in to your account after this time, it will be deleted." - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[src="img.php?size=5"] - input: vImageCodP - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("Logowanie nie udane!")) - test: - path: index.php - selector: a[href^="logout.php"] - -search: - paths: - # https://torrentleech.pl/browse.php?c60=1&c53=1&search=&incldead=1&titlesearch=0&polish=0&cat_film=&napisy=0 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 onlyactive, 1 all, 2 onlydead, 3 free - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - # 0 title, 1 descr, 2 both - titlesearch: "{{ if .Query.IMDBID }}1{{ else }}0{{ end }}" - # 0 all, 1 polish, 2 not polish - polish: 0 - cat_film: "" - # subtitles: 0 everything, 1 yes, 2 no - napisy: 0 - # can search by genre but you need the id. &cat_film=1 for akcja. - - keywordsfilters: - # remove the year from the titles as the site chokes on them during search #7424 - - name: re_replace - args: [" +(?:19|20)\\d{2} *$", ""] - - rows: - selector: table[border="1"][cellspacing="0"][cellpadding="5"] > tbody > tr:has(a[href^="details.php?id="]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_raw: - selector: a[href^="details.php?id="] - title_stripped: - selector: a[href^="details.php?id="] - filters: - - name: re_replace - args: ["^(.*/)(.*)$", "$2"] - - name: trim - title: - text: "{{ if .Config.drop_polish_prefix }}{{ .Result.title_stripped }}{{ else }}{{ .Result.title_raw }}{{ end }}" - filters: - - name: re_replace - args: ["(?i)^\\[PACK\\]\\s*", ""] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php/"] - attribute: href - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - genre: - selector: font[color="blue"] - filters: - - name: validate - args: "akcja, animacja, anime, biograficzny, czarna komedia, dokumentalny, dramat, dramat historyczny, edukacyjny, etiuda, familijny, fantasy, gangsterski, historyczny, horror, katastroficzny, komedia, komedia kryminalna, komedia obyczajowa, komedia romantyczna, krótkometrażowy, kryminał, melodramat, musical, muzyczny, niemy, przygodowy, romans, sci-fi, sensacyjny, sportowy, szpiegowski, thriller, western, wojenny" - _language: - optional: true - selector: img[src="pic/pl.jpg"] - filters: - - name: append - args: "Language: polish\n
" - _subbed: - optional: true - selector: img[src="pic/napisy.gif"] - filters: - - name: append - args: "Subbed\n
" - description: - text: "{{ .Result._language }}{{ .Result._subbed }}{{ if .Result.genre }}Genres: {{ .Result.genre }}{{ else }}{{ end }}" - date: - selector: td:nth-child(4) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - filters: - - name: split - args: ["/", 0] - leechers: - selector: td:nth-child(7) - filters: - - name: split - args: ["/", 1] - downloadvolumefactor: - case: - tr.darmowy: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentleech.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentleech.yml deleted file mode 100644 index 673227e4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentleech.yml +++ /dev/null @@ -1,223 +0,0 @@ ---- -id: torrentleech -name: TorrentLeech -description: "TorrentLeech (TL) is a Private Torrent Tracker for 0DAY / GENERAL. not here _ not scene" -language: en-US -type: private -encoding: UTF-8 -requestDelay: 4.1 # #13796 -links: - - https://www.torrentleech.org/ - - https://www.torrentleech.cc/ - - https://www.torrentleech.me/ - - https://www.tleechreload.org/ - - https://www.tlgetin.cc/ - - https://rss.torrentleech.cc/ -legacylinks: - - https://v4.torrentleech.org/ - -caps: - categorymappings: - - {id: 8, cat: Movies/SD, desc: "Movies Cam"} - - {id: 9, cat: Movies/SD, desc: "Movies TS/TC"} - - {id: 11, cat: Movies/SD, desc: "Movies DVDRip/DVDScreener"} - - {id: 37, cat: Movies/WEB-DL, desc: "Movies WEBRip"} - - {id: 43, cat: Movies/HD, desc: "Movies HDRip"} - - {id: 14, cat: Movies/HD, desc: "Movies BlurayRip"} - - {id: 12, cat: Movies/DVD, desc: "Movies DVD-R"} - - {id: 13, cat: Movies/BluRay, desc: "Movies Bluray"} - - {id: 47, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 15, cat: Movies, desc: "Movies Boxsets"} - - {id: 29, cat: Movies/Other, desc: "Documentaries"} - - - {id: 26, cat: TV/SD, desc: "TV Episodes"} - - {id: 32, cat: TV/HD, desc: "TV Episodes HD"} - - {id: 27, cat: TV, desc: "TV Boxsets"} - - - {id: 17, cat: PC/Games, desc: "Games PC"} - - {id: 42, cat: PC/Mac, desc: "Games Mac"} - - {id: 18, cat: Console/XBox, desc: "Games XBOX"} - - {id: 19, cat: Console/XBox 360, desc: "Games XBOX360"} - - {id: 40, cat: Console/XBox One, desc: "Games XBOXONE"} - - {id: 20, cat: Console/PS3, desc: "Games PS2"} - - {id: 21, cat: Console/PS3, desc: "Games Mac"} - - {id: 39, cat: Console/PS4, desc: "Games PS4"} - - {id: 49, cat: Console/PS4, desc: "Games PS5"} - - {id: 22, cat: Console/PSP, desc: "Games PSP"} - - {id: 28, cat: Console/Wii, desc: "Games Wii"} - - {id: 30, cat: Console/NDS, desc: "Games Nintendo DS"} - - {id: 48, cat: Console/Other, desc: "Games Nintendo Switch"} - - - {id: 23, cat: PC/ISO, desc: "PC ISO"} - - {id: 24, cat: PC/Mac, desc: "PC Mac"} - - {id: 25, cat: PC/Mobile-Other, desc: "PC Mobile"} - - {id: 33, cat: PC/0day, desc: "PC 0-day"} - - {id: 38, cat: Other, desc: "Education"} - - - {id: 34, cat: TV/Anime, desc: "TV Anime"} - - {id: 35, cat: TV, desc: "TV Cartoons"} - - - {id: 45, cat: Books/EBook, desc: "Books EBooks"} - - {id: 46, cat: Books/Comics, desc: "Books Comics"} - - - {id: 31, cat: Audio, desc: "Audio"} - - {id: 16, cat: Audio/Video, desc: "Music videos"} - - - {id: 36, cat: Movies/Foreign, desc: "Movies Foreign"} - - {id: 44, cat: TV/Foreign, desc: "TV Foreign"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: alt2fatoken - type: text - label: Alt 2FA Token - - name: info_alt2fatoken - type: info - label: "About Alt 2FA Token" - default: "(Site Profile => Alt 2FA Token) Only fill in the Alt 2FA Token if you have enabled 2FA on the TorrentLeech Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: exclude_scene - type: checkbox - label: Exclude SCENE torrents from results - default: false - - name: exclude_archives - type: checkbox - label: Exclude torrents with the tag RAR from results (does not support IMDBID search) - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - nameSort: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: user/account/login/ - method: form - form: form[name="login-form"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - alt2FAToken: "{{ .Config.alt2fatoken }}" - error: - - selector: p.text-danger - - selector: .login-container h2:contains("One Time Password") - message: - text: "Your TorrentLeech account has 2FA enabled. Please recheck your indexer settings." - - selector: form[name="login-form"] - message: - text: "Login page detected at {{ .Config.sitelink }}." - test: - path: / - selector: a[href="/user/account/logout"] - -search: - paths: - # "newfilter/2" include 0day and music - # https://www.torrentleech.org/torrents/browse/list/categories/29,2,26,27,32,44,7,34,35/facets/tags:FREELEECH,nonscene/exact/1/query/friends/orderby/added/order/desc - - path: "torrents/browse/list{{ if .Categories }}/categories/{{ join .Categories \",\" }}{{ else }}{{ end }}{{ if or .Config.freeleech .Config.exclude_scene }}/facets/tags:{{ else }}{{ end }}{{ if .Config.freeleech }}FREELEECH{{ else }}{{ end }}{{ if and .Config.freeleech .Config.exclude_scene }},{{ else }}{{ end }}{{ if .Config.exclude_scene }}nonscene{{ else }}{{ end }}/{{ if .Query.IMDBID }}imdbID/{{ .Query.IMDBID }}/{{ else }}exact/1/query/{{ .Keywords }}/{{ end }}{{ if .Keywords }}{{ else }}newfilter/2/{{ end }}orderby/{{ .Config.sort }}/order/{{ .Config.type }}" - response: - type: json - - keywordsfilters: - # remove dashes at the beginning of keywords as they exclude search strings (see issue #3096) - - name: re_replace - args: ["(^|\\s)-", " "] - - name: append - args: "{{ if .Config.exclude_archives }} -tags:rar{{ else }}{{ end }}" - rows: - selector: torrentList - count: - selector: $.numFound - - fields: - category: - selector: categoryID - title_test: # title can be null #13736 - selector: name - optional: true - filters: - # remove [REQ]/[REQUEST] - - name: re_replace - args: ["(?i)^\\[REQ(UEST(ED)?)?\\]\\s*", ""] - title: - text: "{{ if .Result.title_test }}{{ .Result.title_test }}{{ else }}TorrentLeech did not provide a title{{ end }}" - _id: - selector: fid - _filename: - selector: filename - details: - text: "/torrent/{{ .Result._id }}" - download: - text: "/download/{{ .Result._id }}/{{ .Result._filename }}" - genre: - selector: tags - filters: - - name: validate - args: "Action, Adventure, Animation, Biography, Comedy, Crime, Documentary, Drama, Family, Fantasy, Game-Show, History, Horror, Kids, Music, Musical, Mystery, News, Reality-TV, Romance, Sci-Fi, Science-Fiction, Sport, Soap, Talk-Show, Thriller, War, Western" - description: - text: "{{ .Result.genre }}" - imdbid: - selector: imdbID - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: completed - date: - # 2021-10-25 02:18:31 - selector: addedTimestamp - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss" - size: - selector: size - downloadvolumefactor: - # freeleech #6579 #6624 #7367 - selector: download_multiplier - case: - 0: 0 # freeleech - "*": 1 # not free - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 10 days for registered users, less for upgraded users - # 10 day (as seconds = 7 x 24 x 60 x 60) - text: 864000 -# json engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentlt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentlt.yml deleted file mode 100644 index 98c9d22b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentlt.yml +++ /dev/null @@ -1,203 +0,0 @@ ---- -id: torrentlt -name: Torrent.LT -description: "Torrent.LT is a LITHUANIAN Private Torrent Tracker for 0DAY / GENERAL" -language: lt-LT -type: private -encoding: UTF-8 -links: - - https://torrent.lt/ -legacylinks: - - http://www.torrent.ai/ - - https://torrent.ai/ - -caps: - categorymappings: - - {id: 33, cat: Movies, desc: "Filmai / LT"} - - {id: 43, cat: Movies, desc: "Filmai / LT-Subs"} - - {id: 74, cat: Movies/HD, desc: "Filmai LT HD"} - - {id: 34, cat: Movies, desc: "Filmai / Eng"} - - {id: 54, cat: Movies/HD, desc: "Filmai / Eng HD"} - - {id: 32, cat: Movies, desc: "Filmai / Rus"} - - {id: 78, cat: Movies/HD, desc: "Filmai Rus HD"} - - {id: 72, cat: Movies/3D, desc: "Filmai / 3D"} - - {id: 31, cat: Movies/DVD, desc: "Filmai / DVD"} - - - {id: 76, cat: TV, desc: "Animacija / LT"} - - {id: 80, cat: TV/HD, desc: "Animacija / LT HD"} - - {id: 27, cat: TV, desc: "Animacija / Eng"} - - {id: 52, cat: TV/HD, desc: "Animacija / Eng HD"} - - {id: 81, cat: TV, desc: "Animacija / Rus"} - - {id: 85, cat: TV, desc: "Animacija / Rus HD"} - - {id: 86, cat: TV, desc: "Animacija / 3D"} - - {id: 35, cat: TV/Anime, desc: "Anime"} - - - {id: 58, cat: TV, desc: "Serialai / LT"} - - {id: 79, cat: TV/HD, desc: "Serialai LT HD"} - - {id: 42, cat: TV, desc: "Serialai / Eng"} - - {id: 56, cat: TV/HD, desc: "Serialai / Eng HD"} - - {id: 59, cat: TV, desc: "Serialai / Rus"} - - {id: 84, cat: TV/HD, desc: "Serialai / Rus HD"} - - - {id: 69, cat: TV, desc: "TV / LT"} - - {id: 28, cat: TV, desc: "TV / Eng"} - - {id: 70, cat: TV, desc: "TV / Rus"} - - {id: 39, cat: TV/Documentary, desc: "TV / Dokumentika"} - - - {id: 1, cat: PC/Games, desc: "Žaidimai / PC"} - - {id: 50, cat: Console/PS4, desc: "Žaidimai / PS"} - - {id: 51, cat: Console/XBox 360, desc: "Žaidimai / XBOX"} - - {id: 73, cat: Console, desc: "Žaidimai / Priedai"} - - {id: 36, cat: Console/Other, desc: "Žaidimai / Kita"} - - - {id: 62, cat: TV/Sport, desc: "Sport / Basketball"} - - {id: 66, cat: TV/Sport, desc: "Sport / Football"} - - {id: 65, cat: TV/Sport, desc: "Sport / Fights"} - - {id: 63, cat: TV/Sport, desc: "Sport / Cars"} - - {id: 40, cat: TV/Sport, desc: "Sport / Kita"} - - - {id: 87, cat: Audio/MP3, desc: "Muzika / LT"} - - {id: 6, cat: Audio, desc: "Muzika / Albumai"} - - {id: 57, cat: Audio/MP3, desc: "Muzika / VA"} - - {id: 26, cat: Audio, desc: "Muzika / DJ Sets"} - - {id: 29, cat: Audio/Video, desc: "Muzika / Videos"} - - - {id: 10, cat: PC/0day, desc: "Soft / PC"} - - {id: 44, cat: PC/Mac, desc: "Soft / MAC"} - - {id: 61, cat: PC/Mobile-Other, desc: "Soft / Mobile"} - - {id: 75, cat: PC, desc: "Soft / Kita"} - - - {id: 89, cat: Audio/Audiobook, desc: "Audiobooks / LT"} - - {id: 83, cat: Audio/Audiobook, desc: "Audiobooks"} - - {id: 90, cat: Books/EBook, desc: "E-Books / LT"} - - {id: 41, cat: Books/EBook, desc: "E-Books"} - - {id: 77, cat: Other, desc: "Educational"} - - {id: 30, cat: Other/Misc, desc: "Kita"} - - - {id: 21, cat: XXX, desc: "pr0n"} - - {id: 88, cat: XXX, desc: "pr0n / LT"} - - {id: 71, cat: XXX/Pack, desc: "pr0n / pack"} - - {id: 82, cat: XXX, desc: "pr0n / games"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: created - 6: seeders - 4: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: lt/account-login.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.klaida - - selector: p.error_text - test: - path: lt/main - selector: a[href="myaccount"] - -search: - paths: - - path: lt/torrents - inputs: - $raw: "{{ range .Categories }}cats[]={{.}}&{{end}}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - free: "{{ if .Config.freeleech }}on{{ else }}{{ end }}" - keywordsfilters: - - name: replace - args: [".", " "] # issue #3296 - - name: re_replace - args: ["[^\\w\\d\\(\\)]+", "%"] - - rows: - selector: table> tbody > tr[class^="torrents-table__"] - filters: - - name: andmatch - args: 50 - after: 1 - - fields: - category: - selector: td[class^="category-icon_cell"] a - attribute: href - filters: - - name: querystring - args: cats - title: - selector: div[class^="torrent-name"] a - details: - selector: div[class^="torrent-name"] a - attribute: href - download: - selector: td a[href^="download?id="] - attribute: href - poster: - selector: div[class^="torrent-name"] a - attribute: data-poster-preview - seeders: - selector: td.seeders_cell:not(:has(i)) - optional: true - default: 0 - leechers: - selector: td.leechers_cell:not(:has(i)) - optional: true - default: 0 - size: - selector: td.size_cell - description: - case: - img[src$="/checked.png"]: Verified - "*": Unverified - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - date: - selector: span.datetime - attribute: title - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "yyyy-MM-dd HH:mm zzz" - minimumratio: - text: 0.41 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentmasters.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentmasters.yml deleted file mode 100644 index a5a9795a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentmasters.yml +++ /dev/null @@ -1,181 +0,0 @@ ---- -id: torrentmasters -name: TorrentMasters -description: "TorrentMasters is a HUNGARIAN semi-private tracker for MOVIES / TV / GENERAL" -language: hu-HU -type: semi-private -encoding: UTF-8 -links: - - https://torrentmasters.org/ -legacylinks: - - https://torrentmasters.info/ - -caps: - categorymappings: - - {id: 92, cat: Books/EBook, desc: "e-Book (ENG)"} - - {id: 78, cat: Books/EBook, desc: "e-Book (HUN)"} - - {id: 80, cat: Other, desc: "Egyéb"} - - {id: 111, cat: Movies/SD, desc: "Film - Cam (HUN)"} - - {id: 85, cat: Movies/DVD, desc: "Film - DVD/9 (ENG)"} - - {id: 66, cat: Movies/DVD, desc: "Film - DVD/9 (HUN)"} - - {id: 84, cat: Movies/HD, desc: "Film - HD (ENG)"} - - {id: 68, cat: Movies/HD, desc: "Film - HD (HUN)"} - - {id: 82, cat: Movies/SD, desc: "Film - SD (ENG)"} - - {id: 64, cat: Movies/SD, desc: "Film - SD (HUN)"} - - {id: 73, cat: Console, desc: "Játék - Konzol"} - - {id: 94, cat: PC/Games, desc: "Játék - RIP"} - - {id: 72, cat: PC/Games, desc: "Játek - ISO"} - - {id: 79, cat: PC/Mobile-Other, desc: "Mobil/PDA"} - - {id: 95, cat: PC, desc: "Programok - Egyeb (Win)"} - - {id: 74, cat: PC/ISO, desc: "Programok - ISO (Win)"} - - {id: 75, cat: PC/Mac, desc: "Programok - Linux/Unix/Mac OS"} - - {id: 101, cat: TV/SD, desc: "Sorozat - DVD (ENG)"} - - {id: 100, cat: TV/SD, desc: "Sorozat - DVD (HUN)"} - - {id: 89, cat: TV/HD, desc: "Sorozat - HD (ENG)"} - - {id: 97, cat: TV/HD, desc: "Sorozat - HD (HUN)"} - - {id: 86, cat: TV/SD, desc: "Sorozat - SD (ENG)"} - - {id: 90, cat: TV/SD, desc: "Sorozat - SD (HUN)"} - - {id: 99, cat: XXX/DVD, desc: "XXX - DVD"} - - {id: 98, cat: XXX/x264, desc: "XXX - HD"} - - {id: 76, cat: XXX/ImageSet, desc: "XXX - Kepek"} - - {id: 69, cat: XXX/XviD, desc: "XXX - SD"} - - {id: 70, cat: Audio, desc: "Zene - HUN"} - - {id: 71, cat: Audio, desc: "Zene - Külföld"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: sort - type: select - label: Sort requested from site - default: 0 - options: - 0: added - 1: title - 2: size - 4: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek száma egy oldalon setting to 100 on your account profile. - -login: - # using cookie method because login page has embedded Google reCAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index - selector: a[href="/logout/"] - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - paths: - - path: browse/ - inputs: - # https://torrentmasters.info/browse/?sent=yes&sort=0&type=&viewMode=normal&search=2023&incldead=0 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - search: "{{ .Keywords }}" - # 0 active, 1 active+dead, 2 dead, 3 my torrents, 4 requested, 9 bookmarked, 7 featured, 8 main page featured - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - viewMode: normal - sent: yes - # does not support imdbid searches - # can search by genre but need range support. &mufaj[]=akció&mufaj[]animációs - - rows: - selector: table.table > tbody > tr - - fields: - category: - selector: a[href*="categories[]="] - attribute: href - filters: - - name: querystring - args: "categories[]" - title: - selector: a[href^="/details/"] - attribute: title - filters: - - name: regexp - args: "Torrent név: (.*?)
" - details: - selector: a[href^="/details/"] - attribute: href - _id: - selector: a[href^="/details/"] - attribute: href - filters: - - name: regexp - args: (\d+) - download: - text: "/download/{{ .Result._id }}/" - title_hungarian: - selector: a[href^="/details/"] - attribute: title - filters: - - name: regexp - args: "Magyar/Külföldi cím: (.*?)
" - date: - selector: td:nth-child(3) - filters: - - name: append - args: " +01:00" # CET - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - poster: - selector: a[href^="/details/"] - attribute: title - filters: - - name: regexp - args: "src='(.*?)'" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(2) - seeders: - selector: td:nth-last-child(4) - leechers: - selector: td:nth-last-child(3) - uploadvolumefactor_custom: - selector: td:nth-last-child(5) small - optional: true - downloadvolumefactor: - text: 0 - uploadvolumefactor: - case: - i.fa-arrow-up: "{{ .Result.uploadvolumefactor_custom }}" - "*": 1 - genre: - selector: td:nth-child(2) small - description: - text: "{{ .Result.title_hungarian }}{{ if and .Result.title_hungarian .Result.genre }}
{{ else }}{{ end }}{{ .Result.genre }}" - minimumseedtime: - # 24 hours (as seconds = 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentoyunindir.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentoyunindir.yml deleted file mode 100644 index 439f8eb0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentoyunindir.yml +++ /dev/null @@ -1,70 +0,0 @@ ---- -id: torrentoyunindir -name: Torrent Oyun indir -description: "Torrent Oyun indir is a TURKISH Public torrent site for GAMES" -language: tr-TR -type: public -encoding: UTF-8 -links: - - https://0.torrentoyunindir.com/ -legacylinks: - - https://www.torrentoyunindir.com/ - - http://0.torrentoyunindir.com/ - -caps: - categories: - 1: PC/Games - - modes: - search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - selectors: - - selector: div.facepaylas a - attribute: href - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - - rows: - selector: div.moviefilm - filters: - - name: andmatch - - fields: - category: - text: 1 - title: - selector: div.movief - details: - selector: a - attribute: href - download: - selector: a - attribute: href - description: - selector: a > span - attribute: class - poster: - selector: img - attribute: src - date: - text: now - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentproject2.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentproject2.yml deleted file mode 100644 index a27af79d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentproject2.yml +++ /dev/null @@ -1,117 +0,0 @@ ---- -id: torrentproject2 -name: TorrentProject2 -description: "TorrentProject2 is a Public torrent meta-search engine" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 2 -links: - - https://torrentproject2.net/ - - https://torrentproject2.org/ - - https://torrentproject.info/ - - https://torrentproject.biz/ - - https://torrentproject.xyz/ - - https://torrentproject.cc/ - - https://torrentproject.torrentbay.st/ -legacylinks: - - https://torrentproject2.se/ - - https://torrentproject2.com/ - -caps: - categorymappings: - - {id: Other, cat: Other, desc: Other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: filter-verified - type: checkbox - label: "Only include verifed content in results" - default: false - - name: sort - type: select - label: Sort requested from site - default: latest - options: - latest: "created desc" - oldest: "created asc" - seeders: seeders - size: size - - name: info_category_8000 - type: info_category_8000 - -download: - selectors: - - selector: "#download > div:nth-child(2) > div:nth-child(1) > a" - attribute: href - filters: - - name: replace - args: ["https://mylink.me.uk/?url=", ""] - - name: replace - args: ["https://mylink.cx/?url=", ""] - - name: replace - args: ["https://mylink.cloud/?url=", ""] - - name: urldecode - -search: - paths: - # browse for latest, / for keywords, 50 rows per page - - path: "{{ if .Keywords }}/{{ else }}browse{{ end }}" - - path: "{{ if .Keywords }}/{{ else }}browse{{ end }}" - inputs: - p: 1 - inputs: - t: "{{ .Keywords }}" - orderby: "{{ if .Keywords }}{{ .Config.sort }}{{ else }}{{ end }}" - safe: "{{ if and .Keywords .Config.filter-verified }}on{{ else }}{{ end }}" - - headers: - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: "#similarfiles div:has(a[href^=\"/t\"])" - - fields: - category: - # while browse has cats the search does not (atm) so we wont support cats for now. - text: Other - title: - selector: :scope > span > a - details: - selector: :scope > span > a - attribute: href - download: - selector: :scope > span > a - attribute: href - date_ago: - # 7 years ago - selector: :scope > span:nth-child(4):contains("ago") - optional: true - date_time: - # 2020-11-05 07:34:44 - selector: :scope > span:nth-child(4):contains(":") - optional: true - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date: - text: "{{ if or .Result.date_ago .Result.date_time }}{{ or .Result.date_ago .Result.date_time }}{{ else }}now{{ end }}" - size: - selector: :scope > span:nth-child(5) - seeders: - selector: :scope > span:nth-child(2) - leechers: - selector: :scope > span:nth-child(3) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentqq.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentqq.yml deleted file mode 100644 index 73ab48e7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentqq.yml +++ /dev/null @@ -1,117 +0,0 @@ ---- -id: torrentqq -name: TorrentQQ -description: "TorrentQQ (토렌트큐큐) is a Public KOREAN tracker for Korean media." -language: ko-KR -type: public -encoding: UTF-8 -followredirect: true -links: - - https://torrentqq412.com/ -legacylinks: - - https://torrentqq396.com/ - - https://torrentqq398.com/ - - https://torrentqq399.com/ - - https://torrentqq400.com/ - - https://torrentqq401.com/ - - https://torrentqq402.com/ - - https://torrentqq403.com/ - - https://torrentqq404.com/ - - https://torrentqq405.com/ - - https://torrentqq406.com/ - - https://torrentqq407.com/ - - https://torrentqq408.com/ - - https://torrentqq409.com/ - - https://torrentqq410.com/ - - https://torrentqq411.com/ - - https://torrentegg87.com/ - -caps: - categorymappings: - - {id: "adt", cat: XXX, desc: "성인 (XXX)"} - - {id: "ani", cat: TV/Anime, desc: "애니 (Anime)"} - - {id: "etc", cat: Other, desc: "기타 (Other)"} - - {id: "gme", cat: Console, desc: "게임 (Games)"} - - {id: "med", cat: TV, desc: "방송 (TV)"} - - {id: "mov", cat: Movies, desc: "영화 (Movie)"} - - {id: "mus", cat: Audio, desc: "음악 (Music)"} - - {id: "spo", cat: TV/Sport, desc: "스포츠 (Sport)"} - - {id: "utl", cat: PC, desc: "유틸 (Software)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - infohash: - hash: - selector: table.table-bordered > tbody > tr > td > ul > li - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: table.table-bordered > thead > tr > th > strong - filters: - - name: trim - - name: validfilename - -search: - paths: - # https://torrentqq76.com/torrent/newest.html - # https://torrentqq76.com/search?q=cosmic%20sin - - path: "{{ if .Keywords }}search?q={{ .Keywords }}{{ else }}torrent/newest.html{{ end }}" - - rows: - selector: ul#searchresult > li:has(a[href$=".html"][title]) - filters: - - name: andmatch - - fields: - category: - selector: a[href$=".html"][title] - attribute: href - filters: - - name: regexp - args: "\\/torrent\\/(\\w{3})\\/" - title: - selector: a[href$=".html"][title] - details: - selector: a[href$=".html"][title] - attribute: href - download: - selector: a[href$=".html"][title] - attribute: href - date_day: - selector: div.wr-date:contains("-") - optional: true - filters: - - name: dateparse - args: "MM-dd" - date_time: - selector: div.wr-date:contains(":") - optional: true - filters: - - name: dateparse - args: "HH:mm" - date: - text: "{{ if or .Result.date_time .Result.date_day }}{{ or .Result.date_time .Result.date_day }}{{ else }}now{{ end }}" - size: - selector: div.wr-size - filters: - - name: append - args: "B" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentsome.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentsome.yml deleted file mode 100644 index b219c0f6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrentsome.yml +++ /dev/null @@ -1,123 +0,0 @@ ---- -id: torrentsome -name: Torrentsome -description: "Torrentsome is a KOREAN Public tracker for Korean media." -language: ko-KR -type: public -encoding: UTF-8 -followredirect: true -requestDelay: 2 -# to fetch current domain use https://tzip.top/ -links: - - https://torrentsome240.com/ -legacylinks: - - https://torrentsome225.com/ - - https://torrentsome226.com/ - - https://torrentsome227.com/ - - https://torrentsome228.com/ - - https://torrentsome229.com/ - - https://torrentsome230.com/ - - https://torrentsome231.com/ - - https://torrentsome232.com/ - - https://torrentsome233.com/ - - https://torrentsome234.com/ - - https://torrentsome235.com/ - - https://torrentsome236.com/ - - https://torrentsome237.com/ - - https://torrentsome238.com/ - - https://torrentsome239.com/ - -caps: - categorymappings: - - {id: "해외영화", cat: Movies/Foreign, desc: "해외영화 (Foreign Movies)"} - - {id: "한국영화", cat: Movies, desc: "한국영화 (Korean Movies)"} - - {id: "해외드라마", cat: TV, desc: "해외드라마 (Foreign TV)"} - - {id: "한국드라마", cat: TV, desc: "한국드라마 (Korean TV)"} - - {id: "예능·오락", cat: Audio/Video, desc: "예능·오락 (Entertainment)"} - - {id: "다큐·교양", cat: TV/Documentary, desc: "다큐·교양 (Documentary)"} - - {id: "음악", cat: Audio, desc: "음악 (Music)"} - - {id: "유아·어린이", cat: TV, desc: "유아·어린이 (Children)"} - - {id: "애니메이션", cat: TV/Anime, desc: "애니메이션 (Anime)"} - - {id: "만화", cat: Books/Comics, desc: "만화 (Manga)"} - - {id: "넷플릭스 영화", cat: Movies, desc: "넷플릭스 영화 (Netflix Movies)"} - - {id: "넷플릭스 시리즈", cat: TV, desc: "넷플릭스 시리즈 (Netflix TV)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - infohash: - hash: - selector: a[href^="magnet:?xt="] - attribute: href - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: h1.text-base - filters: - - name: trim - - name: validfilename - -search: - paths: - # https://torrentsome185.com/search/index?keywords=2025&search_type=0&order=time - - path: search/index - inputs: - page: 1 - - path: search/index - inputs: - page: 2 - - path: search/index - inputs: - page: 3 - inputs: - keywords: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - search_type: 0 - order: time - - headers: - # site blocks all Linux User-Agents, so use a slightly altered Windows Jackett UA here (e.g. Safari/537.36 > Safari/537.35) - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.35"] - - rows: - selector: div.topic-item:not(:has(div:nth-child(3):contains("-"))) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="/v/"] > span - title: - selector: a[href^="/v/"] - attribute: title - details: - selector: a[href^="/v/"] - attribute: href - download: - selector: a[href^="/v/"] - attribute: href - size: - selector: div:nth-last-child(2) - date: - selector: div:last-child - filters: - - name: dateparse - args: "MM.dd" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenttip.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenttip.yml deleted file mode 100644 index ee6124fa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenttip.yml +++ /dev/null @@ -1,130 +0,0 @@ ---- -id: torrenttip -name: Torrenttip -description: "Torrenttip (토렌트팁) is a Public KOREAN tracker for Korean media." -language: ko-KR -type: public -encoding: UTF-8 -followredirect: true -requestDelay: 2 -# to fetch current domain use https://tzip.top/ -links: - - https://torrenttip221.top/ -legacylinks: - - https://torrenttip206.top/ - - https://torrenttip207.top/ - - https://torrenttip208.top/ - - https://torrenttip209.top/ - - https://torrenttip210.top/ - - https://torrenttip211.top/ - - https://torrenttip212.top/ - - https://torrenttip213.top/ - - https://torrenttip214.top/ - - https://torrenttip215.top/ - - https://torrenttip216.top/ - - https://torrenttip217.top/ - - https://torrenttip218.top/ - - https://torrenttip219.top/ - - https://torrenttip220.top/ - -caps: - categorymappings: - - {id: "한국영화", cat: Movies, desc: "한국영화 (Korean Movies)"} - - {id: "해외영화", cat: Movies/Foreign, desc: "해외영화 (Foreign Movies)"} - - {id: "해외드라마", cat: TV, desc: "해외드라마 (Foreign TV)"} - - {id: "한국드라마", cat: TV, desc: "한국드라마 (Korean TV)"} - - {id: "넷플릭스 영화", cat: Movies, desc: "넷플릭스 영화 (Netflix Movies)"} - - {id: "넷플릭스 시리즈", cat: TV, desc: "넷플릭스 시리즈 (Netflix TV)"} - - {id: "예능·오락", cat: Audio/Video, desc: "예능·오락 (Entertainment)"} - - {id: "다큐·교양", cat: TV/Documentary, desc: "다큐·교양 (Documentary)"} - - {id: "스포츠", cat: TV/Sport, desc: "스포츠 (Sport)"} - - {id: "음악", cat: Audio, desc: "음악 (Music)"} - - {id: "유아·어린이", cat: TV, desc: "유아·어린이 (Children)"} - - {id: "유튜브", cat: TV, desc: "유튜브 (Youtube)"} - - {id: "애니메이션", cat: TV/Anime, desc: "애니메이션 (Anime)"} - - {id: "만화", cat: Books/Comics, desc: "만화 (Manga)"} - - {id: "게임", cat: Console, desc: "게임 (Games)"} - - {id: "모바일", cat: PC/Mobile-Other, desc: "모바일 (Mobile)"} - - {id: "유틸리티", cat: PC, desc: "유틸리티 (Software)"} - - {id: "전자북·강좌", cat: Books/EBook, desc: "전자북·강좌 (EBooks)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - infohash: - hash: - selector: div.p-2:has(i.fa-magnet) - filters: - - name: regexp - args: ([A-F|a-f|0-9]{40}) - title: - selector: h1 - filters: - - name: trim - - name: validfilename - -search: - paths: - # https://torrenttip166.com/search?q=2025&sort=time - - path: search - inputs: - page: 1 - - path: search - inputs: - page: 2 - - path: search - inputs: - page: 3 - inputs: - q: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - sort: time - - headers: - # site blocks all Linux User-Agents, so use a slightly altered Windows Jackett UA here (e.g. Safari/537.36 > Safari/537.35) - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.35"] - - rows: - selector: ul.page-list > li:has(a[href$=".html"][title]) - filters: - - name: andmatch - - fields: - category: - selector: div:nth-child(2) - filters: - - name: regexp - args: "\\[ (.+?) \\]" - title: - selector: a[href$=".html"][title] - attribute: title - details: - selector: a[href$=".html"][title] - attribute: href - download: - selector: a[href$=".html"][title] - attribute: href - date: - selector: div:nth-child(3) - filters: - - name: dateparse - args: "yy/MM/dd" - size: - text: 512MB - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenttrader.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenttrader.yml deleted file mode 100644 index 6f4dfc4e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/torrenttrader.yml +++ /dev/null @@ -1,178 +0,0 @@ ---- -id: torrenttrader -name: Torrent Trader -description: "Torrent Trader is a GERMAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://the-torrent-trader.com/ - -caps: - categorymappings: - - {id: 119, cat: Other, desc: "Bilder"} - - {id: 142, cat: TV/Documentary, desc: "Doku - Sonstige"} - - {id: 120, cat: Books, desc: "Ebook"} - - {id: 135, cat: Movies/HD, desc: "Filme - 1080p"} - - {id: 130, cat: Movies/UHD, desc: "Filme - 2160p"} - - {id: 134, cat: Movies/HD, desc: "Filme - 720p"} - - {id: 148, cat: Movies/DVD, desc: "Filme - DVD/HD2DVD"} - - {id: 146, cat: Movies, desc: "Filme - Klassiker"} - - {id: 105, cat: Movies, desc: "Filme - x264/x265"} - - {id: 127, cat: Movies, desc: "Für Unsere kleinsten"} - - {id: 121, cat: Other, desc: "Handy Stuff"} - - {id: 149, cat: Movies/HD, desc: "Movie Pack HD"} - - {id: 150, cat: Movies/SD, desc: "Movie Pack SD"} - - {id: 124, cat: Audio, desc: "Musik - Alben"} - - {id: 123, cat: Audio, desc: "Musik - Discography"} - - {id: 122, cat: Audio/Audiobook, desc: "Musik - Hörbuch"} - - {id: 133, cat: Audio/MP3, desc: "Musik - MP3/Mp4"} - - {id: 125, cat: Audio/Video, desc: "Musik - Video"} - - {id: 113, cat: PC, desc: "Programme - Linux"} - - {id: 114, cat: PC/Mac, desc: "Programme - Mac"} - - {id: 115, cat: PC/0day, desc: "Programme - Windows"} - - {id: 137, cat: TV/HD, desc: "Serien - 1080p"} - - {id: 138, cat: TV/UHD, desc: "Serien - 2160p"} - - {id: 144, cat: TV/HD, desc: "Serien - 720p"} - - {id: 116, cat: TV, desc: "Serien - Pack"} - - {id: 143, cat: TV/Other, desc: "Serien - Sonstige"} - - {id: 129, cat: Other, desc: "Sonstiges"} - - {id: 151, cat: Other, desc: "Sonstiges"} - - {id: 109, cat: PC/Mobile-Other, desc: "Spiele - Handy"} - - {id: 112, cat: Console, desc: "Spiele - Konsolen"} - - {id: 111, cat: PC/Games, desc: "Spiele - Mac / Linux"} - - {id: 110, cat: PC/Games, desc: "Spiele - PC"} - - {id: 118, cat: TV/Sport, desc: "Sport - Sonstiges"} - - {id: 69, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: onlyupload - type: checkbox - label: Filter OnlyUpload only - default: false - - name: info_free - type: info - label: About OnlyUpload at Torrent Trader - default: "
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up. (On the Prowlarr dashboard search results these are tagged as Freeleech).
" - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeds: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents pro Seite: setting to 100 on your UserCP page. The default is 15. - - name: info_seedhours - type: info - label: Seeding Hours - default: Before using this indexer, make sure that you have set Seed-Angaben on your UserCP page. - -login: - path: login.php - method: form - cookies: ["JAVA=OK"] # avoid jscheck redirect - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - secure_pin: "{{ .Config.pin }}" - returnto: / - error: - - selector: table.tableinborder:contains("Anmeldung Gescheitert!") > tbody > tr > td.tablea - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - showsearch: 1 - # 0 active, 1 all, 2 dead - incldead: 1 - orderby: "{{ .Config.sort }}" - sort: "{{ .Config.type }}" - # site does not support imdbid searching or display imdb links in results. - - rows: - selector: "table.tableinborder > tbody > tr:has(a[href^=\"download.php?torrent=\"]){{ if .Config.onlyupload }}:has(img[src=\"pic/oupic.gif\"]){{ else }}{{ end }}" - filters: - - name: andmatch - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="]:has(b) - details: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(4) > a - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - size: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(1) > b:nth-child(1) - grabs: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(3) > b - files: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(1) > b:nth-child(2) - seeders: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(2) > b:nth-child(1) - leechers: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(2) > b:nth-child(3) - date: - selector: td.content_wrap > table > tbody > tr:nth-child(2) > td:nth-child(5) - filters: - - name: replace - args: ["\u00a0", " "] - - name: append - args: " +01:00" # CET - - name: dateparse - args: "dd.MM.yyyy HH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/oupic.gif"]: 0 # only upload is counted - "*": 1 - uploadvolumefactor: - case: - img[src="pic/freeleech.gif"]: 0 # nothing is counted - "*": 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# NetVision diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/totheglory.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/totheglory.yml deleted file mode 100644 index 88c513a1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/totheglory.yml +++ /dev/null @@ -1,227 +0,0 @@ ---- -id: totheglory -name: ToTheGlory -description: "ToTheGlory (TTG) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://totheglory.im/ - -caps: - # dont forget to update the path categories in the search block - categories: - # 电影 Movie - 电影DVDRip: Movies/SD # Movie DVDRip - 电影720p: Movies/HD # Movie 720p - 电影1080i/p: Movies/HD # Movie 1080i / p - BluRay原盘: Movies/BluRay # BluRay original disc - 影视2160p: Movies/UHD # Media - 2160p - UHD原盘: Movies/UHD # Blu-ray UHD - # 纪录片 Docs - 纪录片720p: Movies/HD # Documentary 720p - 纪录片1080i/p: Movies/HD # Documentary 1080i / p - 纪录片BluRay原盘: Movies/BluRay # Documentary BluRay Original - # 剧集 TV - 欧美剧720p: TV/HD # TV Series - EU/US - 720p - 欧美剧1080i/p: TV/HD # TV Series - EU/US - 1080i/p - 高清日剧: TV/HD # TV Series - JP - 大陆港台剧1080i/p: TV/HD # TV Series - CN - 1080i/p - 大陆港台剧720p: TV/HD # TV Series - CN - 720p - 高清韩剧: TV/HD # TV Series - KR - # 剧集包 TV Packs - 欧美剧包: TV/HD # TV Packs - EU/US - 日剧包: TV/HD # TV Packs - JP - 韩剧包: TV/HD # TV Packs - CN - 华语剧包: TV/HD # TV Packs - KR - # 音乐 Audio - (电影原声&Game)OST: Audio # OST - (Movie & Game) Track - 无损音乐FLAC&APE: Audio/Lossless # FLAC & APE - MV&演唱会: Audio/Video # MV & Concert - # 其他 Other - 高清体育节目: TV/Sport # HD Sports - 高清动漫: TV/Anime # HD Animation - 韩国综艺: TV/HD # TV Shows - KR - 高清综艺: TV/HD # TV Shows - 日本综艺: TV/HD # TV Shows - JP - MiniVideo: Other # MiniVideo - 补充音轨: Audio # Supplemental audio tracks - iPhone/iPad视频: PC/Mobile-Other # IPhone / iPad video - # 单机游戏 - PC: PC/Games # Games - PC - MAC: PC/Mac # Games - MAC - # 微软系列 - XBOX360: Console/XBox 360 # MicroSoft - XBOX360 - XBOX1: Console/XBox # MicroSoft - XBOX360 - XBLA: Console/XBox # MicroSoft - XBLA - XBOX+to+XBOX360: Console/XBox # MicroSoft - XBOX to XBOX360 - # 索尼系列 - PS2: Console/Other # SONY - PS2 - PSP: Console/PSP # SONY - PSP - PS4: Console/PS4 # SONY - PS4 - PS3: Console/PS3 # SONY - PS3 - PSV: Console/PS Vita # SONY - PSV - # 任天堂系列 - WIIU: Console/WiiU # Nintendo - WIIU - WII: Console/Wii # Nintendo- WII - SWITCH: Console/Other # Nintendo - SWITCH - NDS: Console/NDS # Nintendo - NDS - NGC: Console/Other # Nintendo - NGC - # 主机影视 - PS3兼容高清: Console/PS3 # Video - HD - PS3 - PSP兼容高清&标清: Console/PSP # Video - SD&HD - PSP - XBOX360兼容高清: Console/XBox 360 # Video - HD - XBOX360 - Game+Video: Console/Other # Video - Game Video - # 软件 - APPZ: PC # Software - APPZ - Game+Ebook: Books/EBook # Software - Game Ebook - Ebook: Books/EBook # Software - Ebook - # 苹果系列 - iPhone/iPad游戏: PC/Mobile-iOS # Apple - Game - iPhone/iPad - iPad书籍: Books/EBook # Apple - Book - iPad - iPhone/iPad软件: PC/Mobile-iOS # Apple - Software - iPhone/iPad - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: info_2fa - type: info - label: "About 2FA" - default: "If you want to enable 2FA then use the ToTheGlory2FA indexer instead." - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: passid - type: select - label: Security Question ID - default: 0 - options: - 0: Security question (Ignore not set) - 1: When did your start using PT? - 2: How did you get to know about TTG? - 3: What's the name of the first movie you watched in a cinema? - 4: What's your favorite movie? - 5: Who's your favorite porn star? - 6: Who's your favorite actor/actress? - 7: What's your most anticipated movie? - - name: info_passid - type: info - label: "About Security ID" - default: "Only select the Security Question ID from the pulldown if you have set it on the TTG Web Site. Otherwise just leave it as Ignore not set." - - name: passan - type: text - label: Security Question Answer - - name: info_passan - type: info - label: "About Security Question Answer" - default: "Only fill in the Security Question Answer box if you have set it on the TTG Web Site. Don't forget to write your answer in the same language you saved it on the TTG Web Site. Otherwise just leave it empty." - -login: - path: login.php?returnto= - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - rememberme: yes - passid: "{{ .Config.passid }}" - passan: "{{ .Config.passan }}" - error: - - selector: td.embedded:has(h2:contains("!")) # Chinese, Japanese - - selector: td.embedded:has(h2:contains("!")) # English, Korean - test: - path: my.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - inputs: - c: M - - path: browse.php - categories: [1000, 1010, 1020, 1030, 1040, 1050, 1060, 1070, 1080, 1090, 1100, 1110, 1120, 1130, 1140, 1150, 1160, 1170, 1180, 4000, 4010, 4020, 4030, 4040, 4050, 4060, 4070, 7000, 7010, 7020, 7030, 7040, 7050, 7060] - inputs: - c: G - inputs: - search_field: "{{ range .Categories }}分类:`{{.}}` {{end}}{{ if .Query.IMDBID }}imdb{{ .Query.IMDBIDShort }}{{ else }}{{ .Keywords }}{{ end }}" - rows: - selector: table#torrent_table > tbody > tr[id] - - fields: - description: - selector: div.name_left > a > b - title: - selector: div.name_left > a > b - remove: span - category: - selector: tr[id] td:nth-child(1) > a - attribute: href - filters: - - name: querystring - args: search_field - - name: replace - args: ["category:", ""] - - name: trim - args: "\"" - details: - selector: div.name_left > a - attribute: href - download: - selector: a.dl_a - attribute: href - files: - selector: td:nth-child(3) - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(9) - filters: - - name: split - args: ["/", 0] - leechers: - selector: td:nth-child(9) - filters: - - name: split - args: ["/", 1] - - name: replace - args: ["\n", ""] - grabs: - selector: td:nth-child(8) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - imdbid: - selector: span.imdb_rate > a - attribute: href - downloadvolumefactor: - case: - img[alt="free"]: 0 - img[alt="50%"]: 0.5 - img[alt="30%"]: 0.3 - "*": 1 - uploadvolumefactor: - case: - img[alt="200%"]: 2 - "*": 1 - minimumratio: - case: - img[title="Hit and Run"]: 1 - "*": 0 - minimumseedtime: - case: - img[title="Hit and Run"]: 216000 - "*": 0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/totheglory2fa.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/totheglory2fa.yml deleted file mode 100644 index ff4c9d0d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/totheglory2fa.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: totheglory2fa -replaces: - - totheglorycookie -name: ToTheGlory2FA -description: "ToTheGlory (TTG) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL. This indexer uses cookie login for 2FA." -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://totheglory.im/ - -caps: - # dont forget to update the path categories in the search block - categories: - # 电影 Movie - 电影DVDRip: Movies/SD # Movie DVDRip - 电影720p: Movies/HD # Movie 720p - 电影1080i/p: Movies/HD # Movie 1080i / p - BluRay原盘: Movies/BluRay # BluRay original disc - 影视2160p: Movies/UHD # Media - 2160p - UHD原盘: Movies/UHD # Blu-ray UHD - # 纪录片 Docs - 纪录片720p: Movies/HD # Documentary 720p - 纪录片1080i/p: Movies/HD # Documentary 1080i / p - 纪录片BluRay原盘: Movies/BluRay # Documentary BluRay Original - # 剧集 TV - 欧美剧720p: TV/HD # TV Series - EU/US - 720p - 欧美剧1080i/p: TV/HD # TV Series - EU/US - 1080i/p - 高清日剧: TV/HD # TV Series - JP - 大陆港台剧1080i/p: TV/HD # TV Series - CN - 1080i/p - 大陆港台剧720p: TV/HD # TV Series - CN - 720p - 高清韩剧: TV/HD # TV Series - KR - # 剧集包 TV Packs - 欧美剧包: TV/HD # TV Packs - EU/US - 日剧包: TV/HD # TV Packs - JP - 韩剧包: TV/HD # TV Packs - CN - 华语剧包: TV/HD # TV Packs - KR - # 音乐 Audio - (电影原声&Game)OST: Audio # OST - (Movie & Game) Track - 无损音乐FLAC&APE: Audio/Lossless # FLAC & APE - MV&演唱会: Audio/Video # MV & Concert - # 其他 Other - 高清体育节目: TV/Sport # HD Sports - 高清动漫: TV/Anime # HD Animation - 韩国综艺: TV/HD # TV Shows - KR - 高清综艺: TV/HD # TV Shows - 日本综艺: TV/HD # TV Shows - JP - MiniVideo: Other # MiniVideo - 补充音轨: Audio # Supplemental audio tracks - iPhone/iPad视频: PC/Mobile-Other # IPhone / iPad video - # 单机游戏 - PC: PC/Games # Games - PC - MAC: PC/Mac # Games - MAC - # 微软系列 - XBOX360: Console/XBox 360 # MicroSoft - XBOX360 - XBOX1: Console/XBox # MicroSoft - XBOX360 - XBLA: Console/XBox # MicroSoft - XBLA - XBOX+to+XBOX360: Console/XBox # MicroSoft - XBOX to XBOX360 - # 索尼系列 - PS2: Console/Other # SONY - PS2 - PSP: Console/PSP # SONY - PSP - PS4: Console/PS4 # SONY - PS4 - PS3: Console/PS3 # SONY - PS3 - PSV: Console/PS Vita # SONY - PSV - # 任天堂系列 - WIIU: Console/WiiU # Nintendo - WIIU - WII: Console/Wii # Nintendo- WII - SWITCH: Console/Other # Nintendo - SWITCH - NDS: Console/NDS # Nintendo - NDS - NGC: Console/Other # Nintendo - NGC - # 主机影视 - PS3兼容高清: Console/PS3 # Video - HD - PS3 - PSP兼容高清&标清: Console/PSP # Video - SD&HD - PSP - XBOX360兼容高清: Console/XBox 360 # Video - HD - XBOX360 - Game+Video: Console/Other # Video - Game Video - # 软件 - APPZ: PC # Software - APPZ - Game+Ebook: Books/EBook # Software - Game Ebook - Ebook: Books/EBook # Software - Ebook - # 苹果系列 - iPhone/iPad游戏: PC/Mobile-iOS # Apple - Game - iPhone/iPad - iPad书籍: Books/EBook # Apple - Book - iPad - iPhone/iPad软件: PC/Mobile-iOS # Apple - Software - iPhone/iPad - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - -login: - # using cookie method because login page can present second page for 2FA when enabled - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: my.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - inputs: - c: M - - path: browse.php - categories: [1000, 1010, 1020, 1030, 1040, 1050, 1060, 1070, 1080, 1090, 1100, 1110, 1120, 1130, 1140, 1150, 1160, 1170, 1180, 4000, 4010, 4020, 4030, 4040, 4050, 4060, 4070, 7000, 7010, 7020, 7030, 7040, 7050, 7060] - inputs: - c: G - inputs: - search_field: "{{ range .Categories }}分类:`{{.}}` {{end}}{{ if .Query.IMDBID }}imdb{{ .Query.IMDBIDShort }}{{ else }}{{ .Keywords }}{{ end }}" - rows: - selector: table#torrent_table > tbody > tr[id] - - fields: - description: - selector: div.name_left > a > b - title: - selector: div.name_left > a > b - remove: span - category: - selector: tr[id] td:nth-child(1) > a - attribute: href - filters: - - name: querystring - args: search_field - - name: replace - args: ["category:", ""] - - name: trim - args: "\"" - details: - selector: div.name_left > a - attribute: href - download: - selector: a.dl_a - attribute: href - files: - selector: td:nth-child(3) - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(9) - filters: - - name: split - args: ["/", 0] - leechers: - selector: td:nth-child(9) - filters: - - name: split - args: ["/", 1] - - name: replace - args: ["\n", ""] - grabs: - selector: td:nth-child(8) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - imdbid: - selector: span.imdb_rate > a - attribute: href - downloadvolumefactor: - case: - img[alt="free"]: 0 - img[alt="50%"]: 0.5 - img[alt="30%"]: 0.3 - "*": 1 - uploadvolumefactor: - case: - img[alt="200%"]: 2 - "*": 1 - minimumratio: - case: - img[title="Hit and Run"]: 1 - "*": 0 - minimumseedtime: - case: - img[title="Hit and Run"]: 216000 - "*": 0 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trackermk.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trackermk.yml deleted file mode 100644 index 24643f72..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trackermk.yml +++ /dev/null @@ -1,165 +0,0 @@ ---- -id: trackermk -name: TrackerMK -description: "TrackerMK is a MACEDONIAN Private Torrent Tracker for MOVIES / TV / GENERAL" -language: mk-MK -type: private -encoding: UTF-8 -certificates: - - d37b3811042d0868c032a097857b1a98ad583c31 # Expired 30 Dec 2025 -links: - - https://trackermk.com/ -legacylinks: - - http://trackermk.com/ # forces https despite invalid CA - -caps: - categorymappings: - - {id: 75, cat: Movies/UHD, desc: "Movies 4K"} - - {id: 49, cat: Movies/DVD, desc: "Movies DVD-R"} - - {id: 68, cat: PC/Mobile-Android, desc: "Андроид (Android)"} - - {id: 72, cat: PC/Mobile-Android, desc: "Андроид - Апликации (Android)"} - - {id: 47, cat: PC, desc: "Апликации (Software)"} - - {id: 71, cat: PC/Mac, desc: "Апликации - Mac OS"} - - {id: 70, cat: PC/0day, desc: "Апликации - Window 7/8.1/10/11"} - - {id: 63, cat: Audio/Audiobook, desc: "Аудио (Audiobooks)"} - - {id: 60, cat: TV/Documentary, desc: "Документарци (Documentaries)"} - - {id: 61, cat: TV, desc: "Домашни серии (TV Local)"} - - {id: 69, cat: TV, desc: "Домашни серии - Српски / Хрватски / Босански (TV Local)"} - - {id: 57, cat: Books/EBook, desc: "Е-Книги (EBooks)"} - - {id: 53, cat: Console, desc: "Игри (Games)"} - - {id: 54, cat: Audio/Video, desc: "Концерти (Concerts)"} - - {id: 52, cat: Audio/Video, desc: "Музички Спотови (Music Videos)"} - - {id: 67, cat: Other, desc: "Од се по нешто (VIP)"} - - {id: 73, cat: Audio, desc: "Рип од Плоча (Music Rips)"} - - {id: 65, cat: Other, desc: "Слики (pictures)"} - - {id: 55, cat: TV/Sport, desc: "Спорт (sport)"} - - {id: 48, cat: TV/Anime, desc: "Цртани (Animations)"} - - {id: 64, cat: Audio/Lossless, desc: "FLAC"} - - {id: 62, cat: Movies/HD, desc: "Movies HDTV"} - - {id: 50, cat: Audio/MP3, desc: "Mp3"} - - {id: 56, cat: Movies/SD, desc: "Movies Xvid"} - - {id: 59, cat: XXX, desc: "XXX"} - - {id: 74, cat: XXX, desc: "XXX 4K"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "To keep records updated reguarly, all inactive accounts will be deleted after 60 days of inactivity." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - error: - - selector: table:contains("Грешка!") - test: - path: index.php - selector: a[href*="/logout.php?logouthash="] - -search: - paths: - - path: browse.php - keywordsfilters: - - name: re_replace - args: ["[\\s]+", "%"] - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_name - # does not support multi category searching so defaulting to all. - category: 0 - include_dead_torrents: yes - sort: "{{ if .Config.freeleech }}free{{ else }}{{ .Config.sort }}{{ end }}" - order: "{{ if .Config.freeleech }}asc{{ else }}{{ .Config.type }}{{ end }}" - # does not return imdb link in results and t_genre does not return imdbid search results - - rows: - selector: "table.sortable tr:has(a[href*=\"/download.php?id=\"]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="/browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - title: - selector: a[href*="/details.php?id="] - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/download.php?id="] - attribute: href - poster: - selector: img[src*="/torrents/images/"] - attribute: src - date: - selector: td:nth-child(2) - # auto adjusted by site account profile - filters: - - name: regexp - args: "(\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2})" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - downloadvolumefactor: - case: - img[src$="/freedownload.gif"]: 0 - img[src$="/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 24 hours (as seconds = 24 x 60 x 60) - text: 86400 -# TS Special Edition v.7.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trackerzero.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trackerzero.yml deleted file mode 100644 index 542004cf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trackerzero.yml +++ /dev/null @@ -1,151 +0,0 @@ ---- -id: trackerzero -name: TrackerZero -description: "TrackerZero is an ITALIAN Private PAY2DL Torrent Tracker for MOVIES / TV / MUSIC / SOFTWARE" -language: it-IT -type: private -encoding: UTF-8 -links: - - https://trackerzero.it/ -caps: - # https://trackerzero.it/docs#/torrents/list_categories_api_torrents_categories_get - categorymappings: - - {id: 4, cat: Movies/UHD, desc: "4k"} - - {id: 25, cat: TV/Anime, desc: "Anime"} - - {id: 22, cat: PC/0day, desc: "App_Linux"} - - {id: 23, cat: PC/Mac, desc: "App_Mac"} - - {id: 21, cat: PC/0day, desc: "App_Windows"} - - {id: 28, cat: Audio/Audiobook, desc: "Audiolibri"} - - {id: 7, cat: Movies/HD, desc: "BluRay"} - - {id: 24, cat: TV/Anime, desc: "Cartoon"} - - {id: 12, cat: TV/Documentary, desc: "Documentari"} - - {id: 11, cat: TV/HD, desc: "DocuSerie"} - - {id: 8, cat: Movies/DVD, desc: "DVD-R"} - - {id: 5, cat: Movies/SD, desc: "DvdRiP"} - - {id: 14, cat: Audio/Lossless, desc: "Flac"} - - {id: 19, cat: PC/Games, desc: "Giochi_Linux"} - - {id: 20, cat: Console/Other, desc: "Giochi_Mac"} - - {id: 18, cat: PC/Games, desc: "Giochi_PC"} - - {id: 34, cat: Console/PS4, desc: "Giochi PS4"} - - {id: 33, cat: Console/PS4, desc: "Giochi PS5"} - - {id: 27, cat: Books/EBook, desc: "Libri"} - - {id: 13, cat: Audio/MP3, desc: "MP3"} - - {id: 6, cat: Movies/SD, desc: "News_Cinema"} - - {id: 35, cat: Console/NDS, desc: "Nintendo Switch"} - - {id: 30, cat: TV/Other, desc: "Programmi tv"} - - {id: 26, cat: Books/Mags, desc: "Quotidiani_Periodici"} - - {id: 17, cat: Movies/HD, desc: "Saghe"} - - {id: 10, cat: TV/HD, desc: "SerieTV_Italiane"} - - {id: 9, cat: TV/HD, desc: "SerieTV_Straniere"} - - {id: 16, cat: Movies/Other, desc: "Spettacoli_E_Teatro"} - - {id: 32, cat: TV/Sport, desc: "SPORT"} - - {id: 29, cat: Other, desc: "VideoCorsi_E_Guide"} - - {id: 15, cat: Audio/Video, desc: "VideoMusicai"} - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: API key - - name: info_apikey - type: info - label: About your API key - default: "Find your API key by accessing your TrackerZero Profile page and scrolling down to the img.trackerzero.it section." - - name: passkey - type: text - label: Passkey - - name: info_passkey - type: info - label: About your Passkey - default: "Find your Passkey by accessing your TrackerZero Profile page and scrolling down to the Passkey section." - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: api/torrents/latest - method: get - -search: - # docs: https://trackerzero.it/docs - paths: - # https://trackerzero.it/api/torrents?limit=100&page=1&q=pacific&sort_by=created_at&sort_dir=desc - - path: api/torrents - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - limit: 100 - page: 1 - q: "{{ .Keywords }}" - sort_by: "{{ .Config.sort }}" - sort_dir: "{{ .Config.type }}" - # category searching not supported, using it causes 500 internal server error - - keywordsfilters: - - name: diacritics - args: replace - - rows: - selector: items - - fields: - category: - selector: category.id - title: - selector: name - _id: - selector: id - details: - text: "{{ .Config.sitelink }}torrent/{{ .Result._id }}" - download: - text: "{{ .Config.sitelink }}api/torrents/{{ .Result._id }}/download-rss?passkey={{ .Config.passkey }}" - poster: - selector: poster - date: - # "created_at": "2026-02-20T18:17:51.514900+00:00" is returned by Newtonsoft.Json.Linq as 02/20/2026 18:17:51 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: completed - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# json api diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/traht.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/traht.yml deleted file mode 100644 index 55bb49d3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/traht.yml +++ /dev/null @@ -1,91 +0,0 @@ ---- -id: traht -name: TrahT -description: "TrahT is a RUSSIAN Semi-Private Torrent Tracker for 3X" -language: ru-RU -type: semi-private -encoding: windows-1251 -links: - - https://traht.org/ -legacylinks: - - http://traht.org/ - -caps: - categories: - xxx: XXX - - modes: - search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(div.error) - - selector: title:contains("403") - test: - path: / - selector: a[href*="logout.php"] - -search: - paths: - # http://traht.org/browse.php?search=&cat=0&year=0&format=0 - - path: browse.php - inputs: - search: "{{ .Keywords }}" - cat: 0 - year: 0 - format: 0 - - rows: - selector: div[id^="releases-table"] table.embedded tbody tr:has(a[href^="download.php?id="]) - - fields: - category: - text: xxx - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - filters: - - name: append - args: "&ok=" - date: - text: now - size: - selector: td:nth-child(5) - seeders: - selector: td:nth-child(6) - filters: - - name: split - args: ["|", 0] - leechers: - selector: td:nth-child(6) - filters: - - name: split - args: ["|", 1] - description: - selector: img - attribute: title - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# Kinokpk 3.00 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trancetraffic.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trancetraffic.yml deleted file mode 100644 index 8678e284..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trancetraffic.yml +++ /dev/null @@ -1,93 +0,0 @@ ---- -id: trancetraffic -name: TranceTraffic -description: "TranceTraffic is a Private Torrent Tracker for MUSIC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://www.trancetraffic.com/ - -caps: - categories: - 1: Audio - - modes: - search: [q] - music-search: [q, artist] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: td.embedded:has(h2:contains("failed")) - test: - path: index.php - selector: a[href*="logout.php"] - -search: - paths: - - path: browse.php - inputs: - search: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ .Keywords }}{{ end }}" - includeFL: "{{ if .Config.freeleech }}on{{ else }}{{ end }}" - - rows: - selector: table.mainouter table > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - text: 1 - download: - selector: a[href^="download.php/"] - attribute: href - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - filters: - - name: replace - args: ["&hit=1", ""] # avoid redirect - date: - selector: td:nth-child(6) - # auto adjusted by site account profile - filters: - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss" - seeders: - selector: td:nth-child(9) - leechers: - selector: td:nth-child(10) - grabs: - selector: td:nth-child(8) - files: - selector: td:nth-child(4) - size: - selector: td:nth-child(7) - downloadvolumefactor: - case: - "span:contains(\"FREELEECH\")": 0 - "*": 1 - uploadvolumefactor: - text: 1 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trezzor.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trezzor.yml deleted file mode 100644 index 066d99e1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trezzor.yml +++ /dev/null @@ -1,192 +0,0 @@ ---- -id: trezzor -name: TreZzoR -description: "TreZzoR is a CZECH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: cs-CZ -type: private -encoding: windows-1250 -links: - - https://tracker.czech-server.com/ - -caps: - categorymappings: - - {id: 31, cat: Movies/HD, desc: "HD CZ/SK Dabing"} - - {id: 33, cat: Movies/HD, desc: "HD CZ/SK Titulky"} - - {id: 41, cat: TV/HD, desc: "HD Seriály CZ/SK dabing"} - - {id: 42, cat: TV/HD, desc: "HD Seriály CZ/SK titulky"} - - {id: 1, cat: Movies/BluRay, desc: "Full disc CZ/SK dabing"} - - {id: 2, cat: Movies/BluRay, desc: "Full disc CZ/SK titulky"} - - {id: 4, cat: Movies/SD, desc: "SD CZ/SK dabing"} - - {id: 36, cat: Movies/SD, desc: "SD CZ/SK titulky"} - - {id: 7, cat: TV/SD, desc: "Seriály CZ/SK dabing"} - - {id: 37, cat: TV/SD, desc: "Seriály CZ/SK titulky"} - - {id: 35, cat: Audio/Video, desc: "Hudebni video"} - - {id: 32, cat: XXX, desc: "XXX CZ/SK"} - - {id: 13, cat: Audio, desc: "Hudba"} - - {id: 24, cat: Audio/Audiobook, desc: "Mluv. slovo CZ/SK dabing"} - - {id: 14, cat: PC/Games, desc: "Hry"} - - {id: 17, cat: PC/0day, desc: "Programy"} - - {id: 15, cat: PC/Games, desc: "Cestiny,patche,upgrady"} - - {id: 18, cat: Books, desc: "Knihy CZ/SK lokalizace"} - - {id: 19, cat: Books/Comics, desc: "Komiks CZ/SK lokalizace"} - - {id: 16, cat: Other, desc: "Foto,obrázky"} - - {id: 21, cat: PC/Mobile-Android, desc: "Mobilmánia"} - - {id: 20, cat: Console, desc: "Konzole"} - - {id: 43, cat: Other, desc: "Online tvorba"} - - {id: 22, cat: Other, desc: "Ostatní CZ/SK scéna"} - - {id: 23, cat: Other, desc: "Na prani non CZ/SK"} - - modes: - search: [q] - tv-search: [q, season, ep, year] - movie-search: [q, year] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter FreeLeech only - default: false - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - seeds: seeders - size: size - filename: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentu na strance: setting to 100 on your account profile. - - name: info_xxx - type: info - label: XXX Results - default: To include XXX in results, click Kategoria XXX sa nezobrazuje!!!. povolit zobrazenie on the tracker search page. - - name: info_activity - type: info - label: Account Inactivity - default: "Park your account to protect it against deletion due to the fact that you have not logged in at least once every 3 months." - -login: - path: prihlasenie.php - method: post - inputs: - uid: "{{ .Config.username }}" - pwd: "{{ .Config.password }}" - error: - - selector: div > font:has(a[href="neregpodp.php"]) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - search: "{{ .Keywords }}" - category: 0 - # 0 all, 1 active, 2 dead - active: 0 - rok: "{{ .Query.Year }}" - # can search for genre but you need to know the id. &genres=57466 for Akční - - keywordsfilters: - - name: re_replace - args: ["(?i)S(\\d{2})E(\\d{2})", "$1 $2"] - - rows: - selector: "tr.torrenty_lista{{ if .Config.freeleech }}:has(a[href^=\"details.php?slug=\"] b span[style=\"color:darkmagenta\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="torrents.php?onlycat="] - attribute: href - filters: - - name: querystring - args: onlycat - title: - selector: a[href^="details.php?slug="] - attribute: title - filters: - - name: re_replace - args: ["(?i)^(Zobrazit detaily|View details|Detaily): ", ""] - - name: re_replace - args: ["(?i)[\\s\\->]*FREELEECH.*", ""] - - name: re_replace - args: [".*?/ ", ""] - - name: re_replace - args: ["(?i)\\b(\\d{1,3})x(\\d{1,3})\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3}(?:-\\d+)?)[\\.,\\s]*Epizoda[\\s\\.]*(\\d+(?:-\\d+)?)\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(\\d{1,3}(?:-\\d+)?)[\\s\\.]*(?:Sezona|Series?|Seria|Season)[\\.,\\s]*(\\d+(?:-\\d+)?)[\\s\\.]*Epizoda\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3}(?:-\\d+)?)[\\.,\\s]+(\\d+(?:-\\d+)?)[\\s\\.]*Epizoda\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3})[-\\+\\.,\\s]+(\\d+)\\b", "S$1-$2"] - - name: re_replace - args: ["(?i)\\b(\\d{1,3})[-\\+\\.\\s]+(\\d+)[\\s\\.]*(?:Sezona|Series?|Seria|Season)\\b", "S$1-$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3}(?:-\\d+)?)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\b(\\d{1,3}(?:-\\d+)?)[\\s\\.]*(?:Sezona|Series?|Seria|Season)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\bEpizoda[\\s\\.]*(\\d+)[-\\+\\.,\\s]+(\\d+)\\b", "E$1-$2"] - - name: re_replace - args: ["(?i)\\b(\\d+)[-\\+\\.,\\s]+(\\d+)[\\s\\.]*Epizoda\\b", "E$1-$2"] - - name: re_replace - args: ["(?i)\\bEpizoda[\\s\\.]*(\\d+(?:-\\d+)?)\\b", "E$1"] - - name: re_replace - args: ["(?i)\\b(\\d+(?:-\\d+)?)[\\s\\.]*Epizoda\\b", "E$1"] - details: - selector: a[href^="details.php?slug="] - attribute: href - download: - selector: a[href^="download.php?slug="] - attribute: href - date: - selector: td:nth-child(6) - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "d/M/yyyy HH:mm zzz" - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) a - leechers: - selector: td:nth-child(9) a - grabs: - selector: td:nth-child(10) - downloadvolumefactor: - case: - a[href^="details.php?slug="] b span[style="color:darkmagenta"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="details.php?slug="] - attribute: title - filters: - - name: re_replace - args: ["(?i)^(Zobrazit detaily|View details|Detaily): ", ""] - - name: re_replace - args: ["(?i)[\\s\\->]*FREELEECH.*", ""] -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trezzorcookie.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trezzorcookie.yml deleted file mode 100644 index 79f7e60b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/trezzorcookie.yml +++ /dev/null @@ -1,188 +0,0 @@ ---- -id: trezzorcookie -name: TreZzoRCookie -description: "TreZzoR is a CZECH Private Torrent Tracker for MOVIES / TV / GENERAL. This uses the cookie method for access" -language: cs-CZ -type: private -encoding: windows-1250 -links: - - https://tracker.czech-server.com/ - -caps: - categorymappings: - - {id: 31, cat: Movies/HD, desc: "HD CZ/SK Dabing"} - - {id: 33, cat: Movies/HD, desc: "HD CZ/SK Titulky"} - - {id: 41, cat: TV/HD, desc: "HD Seriály CZ/SK dabing"} - - {id: 42, cat: TV/HD, desc: "HD Seriály CZ/SK titulky"} - - {id: 1, cat: Movies/BluRay, desc: "Full disc CZ/SK dabing"} - - {id: 2, cat: Movies/BluRay, desc: "Full disc CZ/SK titulky"} - - {id: 4, cat: Movies/SD, desc: "SD CZ/SK dabing"} - - {id: 36, cat: Movies/SD, desc: "SD CZ/SK titulky"} - - {id: 7, cat: TV/SD, desc: "Seriály CZ/SK dabing"} - - {id: 37, cat: TV/SD, desc: "Seriály CZ/SK titulky"} - - {id: 35, cat: Audio/Video, desc: "Hudebni video"} - - {id: 32, cat: XXX, desc: "XXX CZ/SK"} - - {id: 13, cat: Audio, desc: "Hudba"} - - {id: 24, cat: Audio/Audiobook, desc: "Mluv. slovo CZ/SK dabing"} - - {id: 14, cat: PC/Games, desc: "Hry"} - - {id: 17, cat: PC/0day, desc: "Programy"} - - {id: 15, cat: PC/Games, desc: "Cestiny,patche,upgrady"} - - {id: 18, cat: Books, desc: "Knihy CZ/SK lokalizace"} - - {id: 19, cat: Books/Comics, desc: "Komiks CZ/SK lokalizace"} - - {id: 16, cat: Other, desc: "Foto,obrázky"} - - {id: 21, cat: PC/Mobile-Android, desc: "Mobilmánia"} - - {id: 20, cat: Console, desc: "Konzole"} - - {id: 43, cat: Other, desc: "Online tvorba"} - - {id: 22, cat: Other, desc: "Ostatní CZ/SK scéna"} - - {id: 23, cat: Other, desc: "Na prani non CZ/SK"} - - modes: - search: [q] - tv-search: [q, season, ep, year] - movie-search: [q, year] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Filter FreeLeech only - default: false - - name: sort - type: select - label: Sort requested from site - default: data - options: - data: created - seeds: seeders - size: size - filename: title - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentu na strance: setting to 100 on your account profile. - - name: info_xxx - type: info - label: XXX Results - default: To include XXX in results, click Kategoria XXX sa nezobrazuje!!!. povolit zobrazenie on the tracker search page. - - name: info_activity - type: info - label: Account Inactivity - default: "Park your account to protect it against deletion due to the fact that you have not logged in at least once every 3 months." - -login: - # using the cookie method because the site expects a single session only, so using browser and Jackett can cause a lockout - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - search: "{{ .Keywords }}" - category: 0 - # 0 all, 1 active, 2 dead - active: 0 - rok: "{{ .Query.Year }}" - # can search for genre but you need to know the id. &genres=57466 for Akční - - keywordsfilters: - - name: re_replace - args: ["(?i)S(\\d{2})E(\\d{2})", "$1 $2"] - - rows: - selector: "tr.torrenty_lista{{ if .Config.freeleech }}:has(a[href^=\"details.php?slug=\"] b span[style=\"color:darkmagenta\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="torrents.php?onlycat="] - attribute: href - filters: - - name: querystring - args: onlycat - title: - selector: a[href^="details.php?slug="] - attribute: title - filters: - - name: re_replace - args: ["(?i)^(Zobrazit detaily|View details|Detaily): ", ""] - - name: re_replace - args: ["(?i)[\\s\\->]*FREELEECH.*", ""] - - name: re_replace - args: [".*?/ ", ""] - - name: re_replace - args: ["(?i)\\b(\\d{1,3})x(\\d{1,3})\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3}(?:-\\d+)?)[\\.,\\s]*Epizoda[\\s\\.]*(\\d+(?:-\\d+)?)\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(\\d{1,3}(?:-\\d+)?)[\\s\\.]*(?:Sezona|Series?|Seria|Season)[\\.,\\s]*(\\d+(?:-\\d+)?)[\\s\\.]*Epizoda\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3}(?:-\\d+)?)[\\.,\\s]+(\\d+(?:-\\d+)?)[\\s\\.]*Epizoda\\b", "S$1E$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3})[-\\+\\.,\\s]+(\\d+)\\b", "S$1-$2"] - - name: re_replace - args: ["(?i)\\b(\\d{1,3})[-\\+\\.\\s]+(\\d+)[\\s\\.]*(?:Sezona|Series?|Seria|Season)\\b", "S$1-$2"] - - name: re_replace - args: ["(?i)\\b(?:Sezona|Series?|Seria|Season)[\\s\\.]*(\\d{1,3}(?:-\\d+)?)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\b(\\d{1,3}(?:-\\d+)?)[\\s\\.]*(?:Sezona|Series?|Seria|Season)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\bEpizoda[\\s\\.]*(\\d+)[-\\+\\.,\\s]+(\\d+)\\b", "E$1-$2"] - - name: re_replace - args: ["(?i)\\b(\\d+)[-\\+\\.,\\s]+(\\d+)[\\s\\.]*Epizoda\\b", "E$1-$2"] - - name: re_replace - args: ["(?i)\\bEpizoda[\\s\\.]*(\\d+(?:-\\d+)?)\\b", "E$1"] - - name: re_replace - args: ["(?i)\\b(\\d+(?:-\\d+)?)[\\s\\.]*Epizoda\\b", "E$1"] - details: - selector: a[href^="details.php?slug="] - attribute: href - download: - selector: a[href^="download.php?slug="] - attribute: href - date: - selector: td:nth-child(6) - filters: - - name: append - args: " +02:00" # EET - - name: dateparse - args: "d/M/yyyy HH:mm zzz" - size: - selector: td:nth-child(7) - seeders: - selector: td:nth-child(8) a - leechers: - selector: td:nth-child(9) a - grabs: - selector: td:nth-child(10) - downloadvolumefactor: - case: - a[href^="details.php?slug="] b span[style="color:darkmagenta"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="details.php?slug="] - attribute: title - filters: - - name: re_replace - args: ["(?i)^(Zobrazit detaily|View details|Detaily): ", ""] - - name: re_replace - args: ["(?i)[\\s\\->]*FREELEECH.*", ""] -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tribalmixes.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tribalmixes.yml deleted file mode 100644 index 7b59cdb8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tribalmixes.yml +++ /dev/null @@ -1,140 +0,0 @@ ---- -id: tribalmixes -name: TribalMixes -description: "TribalMixes is a ratioless Semi-Private Torrent Tracker for DJ MIXES" -language: en-GB -type: semi-private -encoding: ISO-8859-1 -links: - - https://www.tribalmixes.com/ - -caps: - categorymappings: - - {id: 1, cat: Audio, desc: "tribal"} - - {id: 2, cat: Audio, desc: "electro"} - - {id: 3, cat: Audio, desc: "chillout"} - - {id: 4, cat: Audio, desc: "house"} - - {id: 5, cat: Audio, desc: "video"} - - {id: 6, cat: Audio, desc: "breaks"} - - {id: 7, cat: Audio, desc: "trance"} - - {id: 8, cat: Audio, desc: "deep house"} - - {id: 9, cat: Audio, desc: "techno"} - - {id: 10, cat: Audio, desc: "drum&bass"} - - {id: 11, cat: Audio, desc: "classic"} - - {id: 12, cat: Audio, desc: "minimal"} - - {id: 14, cat: Audio, desc: "progressive"} - - {id: 15, cat: Audio, desc: "tech house"} - - {id: 16, cat: Audio, desc: "psy-goa trance"} - - {id: 17, cat: Audio, desc: "electro-tribe"} - - {id: 18, cat: Audio, desc: "mega-pack"} - - {id: 19, cat: Audio, desc: "dark"} - - {id: 20, cat: Audio, desc: "dubstep"} - - {id: 22, cat: Audio, desc: "misc"} - - {id: 30, cat: Audio, desc: "(tm) mixes"} - - {id: 31, cat: Audio, desc: "(tm) ONLY"} - - modes: - search: [q] - music-search: [q, artist] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: _ - options: - _: created - 8: seeders - 6: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: div.alert-danger - test: - path: / - selector: a[href="/logout.php"] - -search: - paths: - - path: browse.php - inputs: - search: "{{ if .Query.Artist }}{{ .Query.Artist }}{{ else }}{{ .Keywords }}{{ end }}" - sort: "{{ re_replace .Config.sort \"_\" \"\" }}" - type: "{{ .Config.type }}" - - rows: - selector: div.row:has(a[href^="/download.php?id="]) - - fields: - category: - selector: a[href^="/browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href*="/details.php?id="] - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href^="/download.php?id="] - attribute: href - size: - selector: a[href^="/download.php?id="] - attribute: title - filters: - - name: regexp - args: "Size: (.+?)<" - files: - selector: a[href^="/download.php?id="] - attribute: title - optional: true - default: 1 - filters: - - name: regexp - args: "\\((\\d+) files\\)" - seeders: - selector: a[href^="/download.php?id="] - attribute: title - filters: - - name: regexp - args: ">(\\d+) seeder" - leechers: - selector: a[href^="/download.php?id="] - attribute: title - filters: - - name: regexp - args: ", (\\d+) leecher" - - name: replace - args: ["-1", "0"] - date: - selector: a[href^="/download.php?id="] - attribute: title - filters: - - name: regexp - args: "shared (.+?)<" - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/turkseed-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/turkseed-api.yml deleted file mode 100644 index e7a2b62e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/turkseed-api.yml +++ /dev/null @@ -1,209 +0,0 @@ ---- -id: turkseed-api -name: TurkSeed (API) -description: "TurkSeed (Aturk) is a TURKISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: tr-TR -type: private -encoding: UTF-8 -links: - - https://turkseed.com/ -legacylinks: - - https://turkseed.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies/Foreign, desc: "Yabancı Film"} - - {id: 2, cat: Movies, desc: "Yerli Film"} - - {id: 24, cat: Movies, desc: "Film (Boxet)"} - - {id: 6, cat: TV, desc: "Dizi / Show (Yerli)"} - - {id: 5, cat: TV/Foreign, desc: "Dizi / Show (Yabancı)"} - - {id: 18, cat: TV, desc: "Dizi (Boxet)"} - - {id: 25, cat: TV/Documentary, desc: "Belgesel"} - - {id: 28, cat: Console, desc: "GAME - OYUN"} - - {id: 26, cat: Other, desc: "Other"} - - {id: 27, cat: PC, desc: "PROGRAM"} - - {id: 30, cat: Audio, desc: "Music"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your TurkSeed account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts that do not download a 15GB torrent from our site within a month will receive a warning (will be closed)! Accounts that do not upload (seed, share) 20gb on our site within a month will receive a warning (will be closed)! The account that receives three warnings will be automatically closed! No objections are accepted to the deletion of accounts that have not been on our site for six months and have not been active in any way!" - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Bilim Kurgu)", "Bilim_Kurgu"] - - name: re_replace - args: ["(?i)(Pembe Dizi)", "Pembe_Dizi"] - - name: re_replace - args: ["(?i)(TV film)", "TV_film"] - - name: re_replace - args: ["(?i)(Vahşi Batı)", "Vahşi_Batı"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.6 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.6 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.0.8 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/turktorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/turktorrent.yml deleted file mode 100644 index e57f29e8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/turktorrent.yml +++ /dev/null @@ -1,216 +0,0 @@ ---- -id: turktorrent -name: TurkTorrent -description: "TurkTorrent (TT) is a TURKISH Private PAY2DL Torrent Tracker for MOVIES / TV / GENERAL" -language: tr-TR -type: private -encoding: UTF-8 -links: - - https://turktorrent.us/ -legacylinks: - - http://turktorrent1.in/ - - http://turktorrent.us/ - -caps: - categorymappings: - - {id: 36, cat: Movies, desc: "Film"} - - {id: 59, cat: Movies, desc: "Film Türk Filmi"} - - {id: 30, cat: Movies/HD, desc: "Film X264"} - - {id: 28, cat: Movies/HD, desc: "Film X265"} - - {id: 69, cat: Movies/WEB-DL, desc: "Film H264 WEB-DL"} - - {id: 18, cat: Movies, desc: "Anime - Çizgi"} - - {id: 76, cat: Movies, desc: "Film Yabancı Dil - Altyazısız"} - - {id: 20, cat: Movies, desc: "Film Boxset"} - - {id: 24, cat: Movies/BluRay, desc: "Film 4K/1080P Bluray Disk"} - - {id: 63, cat: Movies/SD, desc: "Film 3D/DVD/XviD"} - - {id: 51, cat: Other, desc: "Diğer"} - - {id: 62, cat: Other, desc: "Diğer Eğitim"} - - {id: 52, cat: Other, desc: "Diğer Belgesel"} - - {id: 53, cat: Other, desc: "Diğer Dini Yayınlar"} - - {id: 54, cat: TV/Sport, desc: "Spor"} - - {id: 56, cat: Other, desc: "Diğer Her Şey"} - - {id: 65, cat: Books/EBook, desc: "E-Kitap"} - - {id: 35, cat: TV, desc: "Netflix"} - - {id: 41, cat: TV, desc: "Netflix Animasyon"} - - {id: 39, cat: TV, desc: "Netflix Film"} - - {id: 38, cat: TV, desc: "Netflix Dizi"} - - {id: 40, cat: TV, desc: "Netflix Belgesel"} - - {id: 104, cat: TV, desc: "Netflix Reality/Yarışma"} - - {id: 47, cat: PC, desc: "Programlar"} - - {id: 48, cat: PC, desc: "Programlar Windows"} - - {id: 96, cat: PC, desc: "Programlar Linux"} - - {id: 49, cat: PC, desc: "Programlar MaC"} - - {id: 50, cat: PC, desc: "Programlar Mobile"} - - {id: 83, cat: TV, desc: "Dijital Platform"} - - {id: 86, cat: TV, desc: "Dijital Platform AMAZON"} - - {id: 102, cat: TV, desc: "Dijital Platform ATVP"} - - {id: 85, cat: TV, desc: "Dijital Platform BEiN/TOD"} - - {id: 84, cat: TV, desc: "Dijital Platform BluTV"} - - {id: 98, cat: TV, desc: "Dijital Platform DSNP"} - - {id: 77, cat: TV, desc: "Dijital Platform EXXEN"} - - {id: 87, cat: TV, desc: "Dijital Platform GAIN"} - - {id: 105, cat: TV, desc: "Dijital Platform Max"} - - {id: 101, cat: TV, desc: "Dijital Platform MUBi"} - - {id: 103, cat: TV, desc: "Dijital Platform TABii"} - - {id: 88, cat: TV, desc: "Dijital Platform TV+"} - - {id: 89, cat: TV, desc: "Dijital Platform Diger"} - - {id: 31, cat: TV, desc: "Dizi"} - - {id: 32, cat: TV, desc: "Dizi Yerli"} - - {id: 33, cat: TV, desc: "Dizi Yabancı / TR Dublaj"} - - {id: 34, cat: TV, desc: "Dizi Boxset"} - - {id: 42, cat: Audio, desc: "Müzik"} - - {id: 43, cat: Audio, desc: "Müzik Yerli"} - - {id: 44, cat: Audio, desc: "Müzik Yabancı"} - - {id: 45, cat: Audio, desc: "Müzik Diskografi"} - - {id: 2, cat: Console, desc: "Oyun"} - - {id: 46, cat: Console, desc: "Oyun PC"} - - {id: 16, cat: Console, desc: "Oyun Konsol"} - - {id: 57, cat: Other, desc: "VIP"} - - {id: 66, cat: Other, desc: "VIP Vip"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: useragent - type: text - label: User-Agent - - name: info_useragent - type: info_useragent - - name: info_profile - type: info - label: Layout - default: "
  • Only the English Classic profile is supported.
  • Make sure to set the Torrent Listing (Listeleme Biçimi) option in your profile to Classic (Klasik)
  • And set the Language (Dil) to English
  • Using the Modern theme will prevent results, and using Turkish will prevent upload dates.
" - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page has embedded hCaptcha - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: ?p=home&pid=1 - selector: a#logout - -search: - headers: - User-Agent: ["{{ .Config.useragent }}"] - paths: - - path: / - inputs: - p: torrents - pid: 32 - $raw: "{{ range .Categories }}cid[]={{.}}&{{end}}" - keywords: "{{ .Keywords }}" - # name description both uploader - search_type: names - "sortOptions[sortBy]": "{{ .Config.sort }}" - "sortOptions[sortOrder]": "{{ .Config.type }}" - - keywordsfilters: - - name: re_replace - args: ["\\b(\\d{4})\\.(\\d{2})\\.(\\d{2})\\b", "$3.$2.$1"] - - rows: - selector: "table#torrents_tables_classic > tbody > tr:has(td.torrent_name){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: div.category_image > a - attribute: href - filters: - - name: querystring - args: cid - title: - selector: a[href*="?p=torrents&pid=10&action=details"] - filters: - - name: re_replace - args: ["\\b(\\d{2})\\.(\\d{2})\\.(\\d{4})\\b", "$3.$2.$1"] - details: - selector: a[href*="?p=torrents&pid=10&action=details"] - attribute: href - date_day: - # Uploaded Friday at 05:11 by - # Uploaded Today at 00:48 by - # Uploaded Yesterday at 23:57 by - selector: td.torrent_name:contains(" at "):not(:has(abbr[data-time])) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: fuzzytime - date_year: - # Uploaded 30-01-2019 15:02 by - selector: td.torrent_name:not(:contains(" at ")):not(:has(abbr[data-time])) - # auto adjusted by site account profile - optional: true - filters: - - name: regexp - args: "Uploaded (.+?) by" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - date_unix: - # within the hour (unix) - selector: abbr[data-time] - attribute: data-time - optional: true - date: - text: "{{ if or .Result.date_day .Result.date_year .Result.date_unix }}{{ or .Result.date_day .Result.date_year .Result.date_unix }}{{ else }}now{{ end }}" - download: - selector: a[href*="?p=torrents&pid=10&action=download"] - attribute: href - magnet: - optional: true - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: a[rel="torrent_size"] - seeders: - selector: a[rel="torrent_seeders"] - leechers: - selector: a[rel="torrent_leechers"] - grabs: - selector: a[rel="times_completed"] - downloadvolumefactor: - case: - "img[src$=\"/freedownload.gif\"]": 0 - "img[src$=\"/silverdownload.gif\"]": 0.5 - "*": 1 - uploadvolumefactor: - case: - "img[src$=\"/torrent_x2.png\"]": 2 - "*": 1 - minimumratio: - text: 0.5 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# TSUE 2.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tvchaosuk.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tvchaosuk.yml deleted file mode 100644 index d752033e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/tvchaosuk.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -id: tvchaosuk -name: TVChaosUK -description: "TV Chaos UK (TVCUK) is a Private Torrent Tracker for UK TV" -language: en-GB -type: private -encoding: UTF-8 -links: - - https://tvchaosuk.com/ - -caps: - categorymappings: - - {id: 29, cat: TV, desc: "Comedy"} - - {id: 45, cat: TV, desc: "Current Affairs"} - - {id: 5, cat: TV/Documentary, desc: "Documentary"} - - {id: 11, cat: TV, desc: "Drama"} - - {id: 14, cat: TV, desc: "Entertainment"} - - {id: 19, cat: TV, desc: "Factual"} - - {id: 43, cat: TV, desc: "Foreign"} - - {id: 32, cat: TV, desc: "Kids & Family"} - - {id: 44, cat: Movies, desc: "Movies"} - - {id: 54, cat: TV, desc: "News"} - - {id: 51, cat: Audio, desc: "Radio"} - - {id: 52, cat: TV, desc: "Reality"} - - {id: 30, cat: TV, desc: "Soaps"} - - {id: 33, cat: TV, desc: "Sci-Fi"} - - {id: 42, cat: TV/Sport, desc: "Sport"} - - {id: 53, cat: TV, desc: "Holding Bin"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login - method: form - form: form[action$="/login"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - remember: on - selectorinputs: - _token: - selector: input[name="_token"] - attribute: value - error: - - selector: div#ERROR_COPY -# test: -# path: / -# selector: a[href$="/logout"] - -search: - paths: - - path: torrents/filter - inputs: - $raw: "{{ range .Categories }}categories[]={{.}}&{{end}}" - search: "{{ if .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid search - imdb: "{{ .Query.IMDBIDShort }}" - tvdb: "{{ .Query.TVDBID }}" - tmdb: "{{ .Query.TMDBID }}" - sorting: "{{ .Config.sort }}" - direction: "{{ .Config.type }}" - qty: 100 - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - view: list - # can search by genre but need range support. &genres[]=Action&genres[]=Comedy for Action and Comedy - keywordsfilters: - # if searching for season packs switch S01 to Series 1 #14369 - - name: re_replace - args: ["(?i)(S0)(\\d{1,2})$", "Series $2"] - - name: re_replace - args: ["(?i)(S)(\\d{1,3})$", "Series $2"] - - rows: - selector: table > tbody > tr - - fields: - category: - selector: a[href*="/categories/"] - attribute: href - filters: - - name: regexp - args: "/categories/(\\d+)" - title: - selector: a.view-torrent - filters: - - name: re_replace - args: ["(?i)Series[\\s]+(\\d+)", "S$1"] - download: - selector: a[href*="/download/"] - attribute: href - details: - selector: a.view-torrent - attribute: href - poster: - selector: div.torrent-poster img - attribute: src - filters: - - name: prepend - args: "https://images.weserv.nl/?url=" # for display on dashboard - - name: append - args: "&w=180&h=270" # for display on dashboard - - name: replace - args: ["https://images.weserv.nl/?url=https://via.placeholder.com/600x900&w=180&h=270", ""] - genre: - selector: td:nth-last-child(6) - filters: - - name: replace - args: ["\n", ""] - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: validate - args: "Action, Adventure, Animation, Biography, Comedy, Crime, Documentary, Drama, Family, Fantasy, Game-Show, History, Horror, Music, Musical, Mystery, News, Reality-TV, Romance, Science_Fiction, Sitcom, Sport, Talk-Show, Thriller, War, Western" - description: - text: "{{ .Result.genre }}" - size: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - grabs: - selector: td:nth-last-child(1) - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - tmdbid: - selector: a[href*="themoviedb.org/movie/"], a[href*="themoviedb.org/tv/"] - attribute: href - date: - selector: time - filters: - # translations for Turkish|Estonian|Danish|Italian|Polish|Norwegian|Portuguese|Czech|Russian|Romanian|Spanish|French|German|Bulgarian|Dutch|Chinese|Japanese|Swedish - - name: re_replace - args: ["(?i)(önce|tagasi|geleden|fa|temu|siden|há|atrás|nazpět|назад|acum|în urmă|hace|il y a|vor|преди|前|sedan)", " ago"] - - name: re_replace - args: ["(?i)(saniye|sekundit|sekunder|secondi|sekund|segundos|sekundami|секунд|secunde|secondes|Sekunden|секунди|seconden|秒前)", "seconds"] - - name: re_replace - args: ["(?i)(minutit|minutter|minuti|minuty|minutos|минуты|минут|Minuten|минути|minuten|minuter)", "minutes"] - - name: re_replace - args: ["(?i)(dakika|minut|minuto|minuta|minutt|минута|Minute|minuut|分钟|分)", " minute"] - - name: re_replace - args: ["(?i)(tundi|timer|ore|godziny|horas|hodiny|hoden|часа|часов|ore|heures|Stunden|timmar)", "hours"] - - name: re_replace - args: ["(?i)(saat|tund|time|ora|godzina|hora|hodina|час|oră|heure|Stunde|uur|小时|時間|timme)", " hour"] - - name: re_replace - args: ["(?i)(päeva|dage|giorni|dni|dias|dny|дня|дней|zile|días|jours|Tagen|дни|dagen|dagar)", "days"] - - name: re_replace - args: ["(?i)(gün|päev|dag|giorno|dzień|dia|den|день|zi|día|jour|Tag|ден|天|日)", " day"] - - name: re_replace - args: ["(?i)(nädalat|uger|settimane|tygodnie|uker|semanas|týdny|недели|недель|săptămâni|semaines|Wochen|седмици|weken|veckor)", "weeks"] - - name: re_replace - args: ["(?i)(hafta|nädal|uge|settimana|tydzień|uke|semana|týden|неделю|săptămână|semaine|Woche|седмица|周|週間|vecka)", " week"] - - name: re_replace - args: ["(?i) (ay)", "month"] - - name: re_replace - args: ["(?i)(kuud|måneder|mesi|miesiące|meses|měsíce|месяца|месяцев|luni|meses|mois|Monaten|месеца|maanden|månader)", "months"] - - name: re_replace - args: ["(?i)(kuu|måned|mese|miesiąc|mês|měsíc|месяц|lună|mes|Monat|месец|maand|个月|ヶ月|månad)", " month"] - - name: re_replace - args: ["(?i)(aastat|anni|lata|anos|roky|года|ani|años|ans|Jahren|години)", " years"] - - name: re_replace - args: ["(?i)(yil|aasta|år|anno|rok|ano|год|año|Jahr|година|jaar|年)", " year"] - - name: re_replace - args: ["(?i) (an)", "year"] - - name: re_replace - args: ["(?i)(För |und)", ""] # Misc removals - - name: timeago - downloadvolumefactor: - case: - i[class*="fa-id-badge text-orange"]: 0 # 24 Hour FreeLeech From BON Store - i[class*="fa-trophy text-purple"]: 0 # Special FreeLeech For Certain User Groups - i[class*="fa-star text-bold"]: 0 # Freeleech From Token - i[class*="fa-coins text-bold"]: 0 # Freeleech From Token - i[class*="fa-globe text-blue"]: 0 # Global Freeleech - i[class*="fa-star text-gold"]: 0 # Freeleech - i[class*="fa-certificate text-pink"]: 0 # Featured Torrent - "*": 1 - uploadvolumefactor: - case: - i[class*="fa-gem text-green"]: 2 # Single Torrent Double Upload - i[class*="fa-globe text-green"]: 2 # Global Double Upload - i[class*="fa-certificate text-pink"]: 2 # Featured Torrent Weekly Freeleech - "*": 1 - minimumratio: - text: 0.4 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# UNIT3D 2.2.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/u2.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/u2.yml deleted file mode 100644 index 9a6eea61..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/u2.yml +++ /dev/null @@ -1,182 +0,0 @@ ---- -id: u2 -name: U2 -description: "U2 (U2分享園@動漫花園) is a CHINESE Private Torrent Tracker for ANIME" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://u2.dmhy.org/ - -caps: - categorymappings: - - {id: 9, cat: TV/Anime, desc: "U2-Rip"} - - {id: 411, cat: TV/Anime, desc: "U2-RBD"} - - {id: 413, cat: TV/Anime, desc: "Web"} - - {id: 12, cat: TV/Anime, desc: "BDRip"} - - {id: 13, cat: TV/Anime, desc: "DVDRip"} - - {id: 14, cat: TV/Anime, desc: "HDTV"} - - {id: 15, cat: TV/Anime, desc: "DVDISO"} - - {id: 16, cat: TV/Anime, desc: "BDMV"} - - {id: 17, cat: TV/Anime, desc: "LQRip"} - - {id: 410, cat: TV/Anime, desc: "外挂结构 (TV Series)"} - - {id: 412, cat: TV/Anime, desc: "加流重灌 (Modded Blu-rays)"} - - {id: 21, cat: Books, desc: "原版漫画 (Raw Books)"} - - {id: 22, cat: Books, desc: "翻译漫画 (Translated Books)"} - - {id: 30, cat: Audio, desc: "Lossless Music"} - - {id: 40, cat: Other, desc: "Others"} - # Anime as Movies (Radarr uses t=movie): - - {id: 9, cat: Movies/Other, desc: "U2-Rip"} - - {id: 411, cat: Movies/Other, desc: "U2-RBD"} - - {id: 413, cat: Movies/Other, desc: "Web"} - - {id: 12, cat: Movies/HD, desc: "BDRip"} - - {id: 13, cat: Movies/Other, desc: "DVDRip"} - - {id: 14, cat: Movies/Other, desc: "HDTV"} - - {id: 15, cat: Movies/Other, desc: "DVDISO"} - - {id: 16, cat: Movies/BluRay, desc: "BDMV"} - - {id: 17, cat: Movies/Other, desc: "LQRip"} - - {id: 412, cat: Movies/BluRay, desc: "加流重灌 (Modded Blu-rays)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - # using the cookie method because the login page uses a custom captcha which is only displayed when you click a button first - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href^="logout.php?key="] - -download: - selectors: - - selector: a[href^="download.php?id="] - attribute: href - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 AniDB, 5 infohash - search_area: 0 - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > time[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(time[title]) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td:nth-child(2) - remove: a, b, font, img -# NexusPHP U2-Revision Standard v1.5 Beta 4 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/u3c3.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/u3c3.yml deleted file mode 100644 index e4206541..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/u3c3.yml +++ /dev/null @@ -1,83 +0,0 @@ ---- -id: u3c3 -name: U3C3 -description: "U3C3 is a CHINESE Public tracker focused on 3X Eastern Asian media" -language: zh-CN -type: public -encoding: UTF-8 -links: - - https://u3c3.com/ - - https://u001.25img.com/ - - https://u002.25img.com/ - - https://u003.25img.com/ - - https://m0m0m1m.mnmnmnmnmn.com/ # numbers in subdomain can be m2m9m9m or less -legacylinks: - - https://u3c3.in/ - - https://a.u3c3.life/ - - https://m0m0m0m.mnmnmnmnmn.com/ - -caps: - categorymappings: - - {id: 國產原创, cat: XXX, desc: 國產原创} - - {id: U3C3, cat: XXX, desc: U3C3} - - {id: Video, cat: XXX, desc: Video} - - {id: Photo, cat: XXX/ImageSet, desc: Photo} - - {id: Book, cat: Books, desc: Book} - - {id: Game, cat: Console, desc: Game} - - {id: Software, cat: PC, desc: Software} - - {id: Other, cat: Other, desc: Other} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -search: - paths: - - path: / - inputs: - search2: "{{ if .Keywords }}eelja3lfe1a1{{ else }}{{ end }}" - search: "{{ .Keywords }}" - - rows: - selector: table.torrent-list > tbody > tr:has(a[href^="/view?id="]) - filters: - - name: andmatch - - fields: - category: - selector: a[href^="/?type="] - attribute: title - title: - selector: a[href^="/view?id="] - details: - selector: a[href^="/view?id="] - attribute: href - download: - selector: a[href^="/torrent/"] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - date: - selector: td:nth-child(5) - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-child(4) - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine bt-btt diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ubits.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ubits.yml deleted file mode 100644 index 9554d015..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ubits.yml +++ /dev/null @@ -1,215 +0,0 @@ ---- -id: ubits -name: UBits -description: "UBits is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ubits.club/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: TV, desc: "TV Series"} - - {id: 403, cat: TV, desc: "TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries"} - - {id: 405, cat: TV/Anime, desc: "Animations"} - - {id: 406, cat: Audio/Video, desc: "Music Videos"} - - {id: 407, cat: TV/Sport, desc: "Sports"} - - {id: 408, cat: Other, desc: "Other"} - - {id: 409, cat: Other, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the UBits Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Userswith a parked account will be deleted if they do not log in for 180 consecutive days
  4. Users with non-parked account will be deleted if they do not log in for 120 consecutive days
  5. Users with no traffic (ie, upload/download data are both 0) will be deleted if they do not log in for 60 consecutive days.
" - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - case: - img.hitandrun: 100 # dummy MR to ensure HnR MST is met - "*": 0.8 - minimumseedtime: - case: - img.hitandrun: 259200 - "*": 8640000 # dummy MST to ensure non-HnR MR is met - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.8.9 2024-03-16 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uindex.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uindex.yml deleted file mode 100644 index 6da57658..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uindex.yml +++ /dev/null @@ -1,71 +0,0 @@ ---- -id: uindex -name: Uindex -description: "Uindex is a Public Torrent Tracker for MOVIES / TV / MUSIC / GENERAL" -language: en-US -type: public -encoding: UTF-8 -links: - - https://uindex.org/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Console, desc: "Games"} - - {id: 4, cat: Audio, desc: "Music"} - - {id: 5, cat: PC, desc: "Apps"} - - {id: 6, cat: XXX, desc: "XXX"} - - {id: 7, cat: TV/Anime, desc: "Anime"} - - {id: 8, cat: Other, desc: "Other"} - # while zero does not return results when c=0 used, results occasionally have this cat instead of eight - - {id: 0, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - -settings: [] - -search: - paths: - # https://uindex.org/search.php?search=complete&c=0 - - path: search.php - allowEmptyInputs: true - inputs: - search: "{{ .Keywords }}" - c: 0 - - rows: - selector: table.sr-table > tbody > tr:has(a[href^="magnet:?xt="]) - - fields: - category: - selector: a[href^="/search.php?c="] - attribute: href - filters: - - name: querystring - args: c - title: - selector: a[href^="/details.php?id="] - details: - selector: a[href^="/details.php?id="] - attribute: href - download: - selector: a[href^="magnet:?xt="] - attribute: href - date: - selector: td.sr-col-uploaded - size: - selector: td.sr-col-size - seeders: - selector: td.sr-col-seeders - leechers: - selector: td.sr-col-leechers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ultrahd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ultrahd.yml deleted file mode 100644 index 845fc643..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ultrahd.yml +++ /dev/null @@ -1,181 +0,0 @@ ---- -id: ultrahd -name: UltraHD -description: "UltraHD is a CHINESE Private Torrent Tracker for UHD MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://ultrahd.net/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies"} - - {id: 402, cat: TV, desc: "TV Series"} - - {id: 403, cat: TV, desc: "TV Shows"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries"} - - {id: 405, cat: TV/Anime, desc: "Animations"} - - {id: 416, cat: Audio/Video, desc: "Music Video"} - - {id: 417, cat: Other, desc: "Other"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Inactive accounts (i.e. not logged in for a long time) are automatically deleted." - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="usercp.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(table.torrentname) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - case: - img.hitandrun: 1209600 # 14 days (as seconds = 14 x 24 x 60 x 60) - "*": 604800 # 7 days (as seconds = 7 x 24 x 60 x 60) - minimumratio: - case: - img.hitandrun: 3.0 - "*": 2.0 - description: - selector: td:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/unionfansub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/unionfansub.yml deleted file mode 100644 index 576b46b7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/unionfansub.yml +++ /dev/null @@ -1,147 +0,0 @@ ---- -id: unionfansub -name: Union Fansub -description: "Union Fansub is a SPANISH Semi-Private torrent site focused on ANIME" -language: es-ES -type: semi-private -encoding: UTF-8 -links: - - https://torrent.unionfansub.com/ -legacylinks: - - http://torrent.unionfansub.com/ - -caps: - categorymappings: - - {id: 13, cat: Movies, desc: "Animación"} - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 2, cat: TV/Anime, desc: "Anime/ISO"} - - {id: 3, cat: TV/Anime, desc: "Anime/RAW"} - - {id: 10, cat: Audio, desc: "BSO / OST"} - - {id: 15, cat: Movies, desc: "Cine"} - - {id: 14, cat: TV/Documentary, desc: "Documentales"} - - {id: 9, cat: TV/Anime, desc: "Dorama"} - - {id: 4, cat: TV/Anime, desc: "Hentai"} - - {id: 18, cat: TV/Anime, desc: "Hentai CG"} - - {id: 6, cat: TV/Anime, desc: "Hentai/ISO"} - - {id: 5, cat: TV/Anime, desc: "Hentai/RAW"} - - {id: 16, cat: TV/Anime, desc: "J-Music"} - - {id: 7, cat: TV/Anime, desc: "Manga"} - - {id: 8, cat: TV/Anime, desc: "Manga/Hentai"} - - {id: 17, cat: Books, desc: "Novelas"} - - {id: 12, cat: TV/Other, desc: "Otros"} - - {id: 11, cat: TV, desc: "Programas"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: https://foro.unionfansub.com/member.php - method: post - inputs: - quick_username: "{{ .Config.username }}" - quick_password: "{{ .Config.password }}" - action: do_login - url: "{{ .Config.sitelink }}" - quick_login: 1 - quick_remember: yes - error: - - selector: div.error - test: - path: / - selector: div#statusbar - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}&c{{.}}=1{{end}}" - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - search: "{{ .Keywords }}" - - rows: - selector: .tlist tr:not(:first-child) - - fields: - category: - selector: td:nth-child(1) a - attribute: href - filters: - - name: querystring - args: cat - title_fansub: - selector: td:nth-child(2) span - optional: true - title_season: - selector: td:nth-child(2) a - filters: - - name: re_replace - args: ["(?i)\\b(\\d{1,4})([ºª])\\b", "$1"] - - name: re_replace - args: ["(?i)\\b(Temporada|Season) (\\d+)\\b", "S$2"] - - name: re_replace - args: ["(?i)\\b(\\d+)(st|nd|rd|th) (Season|Temporada)\\b", "S$1"] - - name: re_replace - args: ["(?i)\\b(Season|Temporada) (\\d+)(st|nd|rd|th)\\b", "S$2"] - title: - text: "{{ .Result.title_season }}{{ if .Result.title_fansub }} [{{ .Result.title_fansub }}]{{ else }}{{ end }}" - details: - selector: td:nth-child(2) a - attribute: href - download: - selector: td:nth-child(2) a - attribute: href - filters: - - name: replace - args: ["details.php?id=", "download.php?torrent="] - - name: replace - args: ["&hit=", "&aviso="] - files: - selector: td:nth-last-child(8) - size: - selector: td:nth-last-child(5) - grabs: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - date: - selector: td:nth-last-child(6) - filters: - - name: timeago - downloadvolumefactor: - text: 1 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/unlimitz.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/unlimitz.yml deleted file mode 100644 index f5486a32..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/unlimitz.yml +++ /dev/null @@ -1,176 +0,0 @@ ---- -id: unlimitz -name: Unlimitz -description: "Unlimitz is a THAI Private Torrent Tracker for GENERAL" -language: th-TH -type: private -encoding: windows-874 -links: - - https://www.unlimitz.biz/ - -caps: - categorymappings: - - {id: 84, cat: Audio, desc: "ธรรมะ [Dharma]"} - - {id: 93, cat: TV/Anime, desc: "การ์ตูน [Cartoon]"} - - {id: 112, cat: TV/Sport, desc: "กีฬา [Sport]"} - - {id: 101, cat: Other, desc: "ฟอนต์/ไอคอน/คลิปอาร์ท/เทมเพลท [Fonts, icons, clip art]"} - - {id: 90, cat: TV, desc: "ซีรีย์ (หนังชุด) [Series]"} - - {id: 92, cat: Console, desc: "เกมส์ (non PC) [games]"} - - {id: 91, cat: PC/Games, desc: "เกมส์ (PC) [games]"} - - {id: 100, cat: PC/Mobile-Other, desc: "โปรแกรมที่ใช้บน (มือถือ) [mobiles]"} - - {id: 82, cat: PC, desc: "โปรแกรมที่ใช้บน (Linux)"} - - {id: 83, cat: PC/0day, desc: "โปรแกรมที่ใช้บน (Windows)"} - - {id: 110, cat: Audio, desc: "เพลงไทย [Thai music]"} - - {id: 119, cat: Other, desc: "เลขเด็ด เลขดัง [lucky number]"} - - {id: 81, cat: PC, desc: "ระบบปฏิบัติการ [PC]"} - - {id: 89, cat: Movies/DVD, desc: "ภาพยนตร์ DVD Master [movies]"} - - {id: 120, cat: Movies/3D, desc: "ภาพยนตร์ 3D [ movies]"} - - {id: 114, cat: Movies/DVD, desc: "ภาพยนตร์ DVD Modified [movies]"} - - {id: 98, cat: Movies/DVD, desc: "ภาพยนตร์ DVD Zoom [movies]"} - - {id: 107, cat: Movies/DVD, desc: "ภาพยนตร์ DVD/VCD Rip [movies]"} - - {id: 103, cat: Movies/HD, desc: "ภาพยนตร์ Hi-DeF [movies]"} - - {id: 113, cat: Movies/HD, desc: "ภาพยนตร์ mini Hi-Def [movies]"} - - {id: 88, cat: Movies, desc: "ภาพยนตร์ VCD Master [movies]"} - - {id: 115, cat: Movies, desc: "ภาพยนตร์ VCD Modified [movies]"} - - {id: 99, cat: Movies, desc: "ภาพยนตร์ VCD Zoom [movies]"} - - {id: 97, cat: TV, desc: "รายการทีวี [TV]"} - - {id: 118, cat: Audio, desc: "รายการวิทยุ [radio]"} - - {id: 111, cat: Audio/Video, desc: "มิวสิค วีดีโอ/การแสดงสด/คาราโอเกะ/ทอล์คโชว์ [music video]"} - - {id: 94, cat: Other, desc: "รูปภาพ (ไม่โป๊,ไม่วาบหวิว) [pictures]"} - - {id: 87, cat: Books, desc: "หนังสือ/สื่อการเรียนรู้/นวนิยาย [books]"} - - {id: 96, cat: TV/Documentary, desc: "สารคดี [documentary]"} - - {id: 108, cat: Other/Misc, desc: "อื่นๆ [other]"} - - {id: 106, cat: XXX, desc: "UnlimitZ Pink (Cartoon)"} - - {id: 104, cat: XXX, desc: "UnlimitZ Pink (Censored)"} - - {id: 116, cat: XXX, desc: "UnlimitZ Pink (Game)"} - - {id: 117, cat: XXX, desc: "UnlimitZ Pink (Rated R)"} - - {id: 105, cat: XXX, desc: "UnlimitZ Pink (Uncensored)"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: staffpass - type: text - label: Staff Pass - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img.cimage - input: captcha - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - staffpass: "{{ .Config.staffpass }}" - error: - - selector: td.embedded:has(h2:contains("failed")) - - selector: td.embedded:has(h2:contains("Error")) - test: - path: index.php - selector: a[href="logout.php"] - -download: - selectors: - - selector: a[href^="d.php?keyalert1="] - attribute: href - filters: - - name: replace - args: ["d.php?keyalert1=", "/dI.php/"] - - name: replace - args: ["&keyalert2=", "/"] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ .Keywords }}" - # 0 title, 1 descr, 2 both - blah: 0 - # 0 active, 1 incldead, 2 onlydead - incldead: 1 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # site does not support imdbid search or provide imdb links in results - - rows: - selector: "table[border=1][cellspacing=0][cellpadding=5] > tbody > tr:has(a[href^=\"details.php?id=\"]){{ if .Config.freeleech }}:has(img[src=\"pic/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="details.php?id="] - attribute: href - files: - selector: td:nth-child(4) - date: - selector: td:nth-child(7) - filters: - - name: append - args: " +07:00" # ICT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - size: - selector: td:nth-child(8) - grabs: - selector: td:nth-child(9) - seeders: - selector: td:nth-child(10) - leechers: - selector: td:nth-child(11) - downloadvolumefactor: - case: - img[src="pic/freedownload.gif"]: 0 - "*": 1 - uploadvolumefactor: - case: - img[src="icon/upl2.png"]: 2 - img[src="icon/upl3.png"]: 3 - "*": 1 - minimumratio: - text: 1.0 -# Bitcomet 1.52 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uploadcx.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uploadcx.yml deleted file mode 100644 index f0ba558b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uploadcx.yml +++ /dev/null @@ -1,191 +0,0 @@ ---- -id: uploadcx -name: upload.cx (API) -description: "upload.cx (ULCX) is a Private Torrent Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://upload.cx/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your upload.cx account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "All members must engage with the site, through either logging in or navigating upload.cx. Any account that remains inactive for over two months will be deactivated and pruned after an additional two months of deactivation." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 2 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# json UNIT3D 9.1.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/upscalevault.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/upscalevault.yml deleted file mode 100644 index 2075ae7c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/upscalevault.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -id: upscalevault-api -name: Upscale Vault (API) -description: "Upscale Vault is a Private Torrent Tracker for AI upscaled and remastered MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://upscalevault.com/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 3, cat: Audio/Video, desc: "Music Videos"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your Upscale Vault account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - # seasonNumber: "{{ .Query.Season }}" # not supported - # episodeNumber: "{{ .Query.Ep }}" # not supported - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - 0: "{{ .False }}" - 1: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.5 but torrents must be seeded for 14 days regardless of ratio -# minimumratio: -# text: 0.5 - minimumseedtime: - # 14 day (as seconds = 14 x 24 x 60 x 60) - text: 1209600 -# json UNIT3D 8.1.3 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/utopia.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/utopia.yml deleted file mode 100644 index f3cba7cf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/utopia.yml +++ /dev/null @@ -1,202 +0,0 @@ ---- -id: utopia -name: UTOPIA (API) -description: "UTOPIA is a UKRAINIAN Private Tracker for HD MOVIES and TV" -language: uk-UA -type: private -encoding: UTF-8 -links: - - https://utp.to/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your UTOPIA account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: addukrainiantotitle - type: checkbox - label: Add UKR to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "If you stop visiting the site or downloading torrents your account will be automatically deactivated after 180 days." - -login: - path: /api/torrents - method: get - inputs: {} # TODO: remove in v10 - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - filters: - - name: append - args: "{{ if .Config.addukrainiantotitle }} UKR{{ else }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - infohash: - selector: info_hash - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Екшн і Пригоди)", "Екшн_і_Пригоди"] - - name: re_replace - args: ["(?i)(Мильна опера)", "Мильна_опера"] - - name: re_replace - args: ["(?i)(Науково фантастичний)", "Науково_фантастичний"] - - name: re_replace - args: ["(?i)(Політика та війна)", "Політика_та_війна"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 3 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# json UNIT3D 9.1.5 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uztracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uztracker.yml deleted file mode 100644 index 88e106fd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/uztracker.yml +++ /dev/null @@ -1,505 +0,0 @@ ---- -id: uztracker -name: UzTracker -description: "UzTracker is an UZBEK Public Torrent Tracker for MOVIES / TV / GENERAL" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://uztracker.net/ - -caps: - categorymappings: - # Кинофильмы # Movies - - {id: 3, cat: Movies/Foreign, desc: "Зарубежные кинофильмы"} - - {id: 13, cat: Movies/Foreign, desc: " |- Новинки 2026"} - - {id: 12, cat: Movies/Foreign, desc: " |- Фильмы 2021-2025 годов"} - - {id: 11, cat: Movies/Foreign, desc: " |- Фильмы 2011-2020 годов"} - - {id: 10, cat: Movies/Foreign, desc: " |- Зарубежные фильмы до 2011 года"} - - {id: 215, cat: Movies/Foreign, desc: " |- Классика мирового кинематографа"} - - {id: 5, cat: Movies/Foreign, desc: " |- Фильмы (HDvideo)"} - - {id: 189, cat: Movies/Foreign, desc: " |- Фильмы в 4K и 3D"} - - {id: 349, cat: Movies/Foreign, desc: " |- Кинофильмы (48-120 FPS)"} - - {id: 8, cat: Movies/Foreign, desc: " |- Фильмологии"} - - {id: 242, cat: Movies/Foreign, desc: " |- Индийское кино"} - - {id: 7, cat: Movies/Foreign, desc: " |- DVDScr, TS, CAMRip и т.п."} - - {id: 6, cat: Movies/Foreign, desc: " |- HD-AVC"} - - {id: 33, cat: Movies/Foreign, desc: " |- Перевод на узбекский"} - - {id: 14, cat: Movies, desc: "Российские кинофильмы"} - - {id: 344, cat: Movies, desc: " |- Новинки 2026"} - - {id: 24, cat: Movies, desc: " |- Российские фильмы 2021-2025 годов"} - - {id: 430, cat: Movies, desc: " |- Российские фильмы 2011-2020 годов"} - - {id: 223, cat: Movies, desc: " |- Кино СССР"} - - {id: 16, cat: Movies, desc: " |- Фильмы (HDvideo)"} - - {id: 190, cat: Movies, desc: " |- Фильмы в 4K и 3D"} - - {id: 34, cat: Movies, desc: " |- Перевод на узбекский"} - - {id: 25, cat: Movies, desc: "Узбекские кинофильмы"} - - {id: 32, cat: Movies, desc: " |- Пр-во Узбекфильм (на русском)"} - - {id: 30, cat: Movies, desc: " |- Пр-во Узбекфильм (на узбекском языке)"} - # Сериалы, Видео и ТВ # Series, Videos and TV - - {id: 410, cat: TV/Foreign, desc: "Зарубежные сериалы"} - - {id: 412, cat: TV/Foreign, desc: " |- Зарубежные сериалы (HD)"} - - {id: 427, cat: TV/Foreign, desc: " |- Зарубежные сериалы (FullHD)"} - - {id: 411, cat: TV/Foreign, desc: " |- Зарубежные сериалы (UHD)"} - - {id: 333, cat: TV, desc: " |- Игра престолов / Game of Thrones"} - - {id: 313, cat: TV, desc: " |- Секретные материалы \ The X-Files"} - - {id: 418, cat: TV, desc: "Российские сериалы"} - - {id: 419, cat: TV, desc: " |- Российские сериалы (HD)"} - - {id: 428, cat: TV, desc: " |- Российские сериалы (FullHD)"} - - {id: 420, cat: TV, desc: " |- Российские сериалы (UHD)"} - - {id: 421, cat: TV/Foreign, desc: "Азиатские сериалы"} - - {id: 423, cat: TV/Foreign, desc: " |- Азиатские сериалы (HD)"} - - {id: 429, cat: TV/Foreign, desc: " |- Азиатские сериалы (FullHD)"} - - {id: 422, cat: TV/Foreign, desc: " |- Азиатские сериалы (UHD)"} - - {id: 97, cat: TV, desc: "Сериалы"} - - {id: 100, cat: TV, desc: " |- Турецкие сериалы"} - - {id: 99, cat: TV, desc: " |- Узбекские сериалы"} - - {id: 398, cat: TV, desc: " |- Подготовка и оформления раздач R.G. Serials"} - # Мультфильмы # Cartoons - - {id: 36, cat: TV/Foreign, desc: "Зарубежные мультфильмы"} - - {id: 48, cat: TV/Foreign, desc: " |- Новинки 2026"} - - {id: 61, cat: TV/Foreign, desc: " |- Мультфильмы 2011-2025 годов"} - - {id: 60, cat: TV/Foreign, desc: " |- Мультфильмы 2000-2010 годов"} - - {id: 59, cat: TV/Foreign, desc: " |- Мультфильмы до 2000 года"} - - {id: 46, cat: TV/Foreign, desc: " |- Полнометражные мультфильмы"} - - {id: 45, cat: TV/Foreign, desc: " |- Короткометражные мультфильмы"} - - {id: 41, cat: TV/Foreign, desc: " |- Иностранные мультфильмы (HD Video)"} - - {id: 188, cat: TV/Foreign, desc: " |- Мультфильмы 4K, 3D"} - - {id: 39, cat: TV/Foreign, desc: " |- Перевод на узбекский"} - - {id: 37, cat: TV, desc: "Российские мультфильмы"} - - {id: 58, cat: TV, desc: " |- Новинки 2025"} - - {id: 56, cat: TV, desc: " |- Полнометражные мультфильмы"} - - {id: 54, cat: TV, desc: " |- Короткометражные мультфильмы"} - - {id: 51, cat: TV, desc: " |- Мультфильмы (HDvideo)"} - - {id: 187, cat: TV, desc: " |- Мультфильмы 4K, 3D"} - - {id: 49, cat: TV, desc: " |- Перевод на узбекский"} - - {id: 403, cat: TV, desc: " |- Узбекские мультфильмы"} - - {id: 98, cat: TV, desc: " |- Мультсериалы"} - # Аниме # Anime - - {id: 345, cat: TV/Anime, desc: "Сериалы"} - - {id: 346, cat: TV/Anime, desc: " |- Новинки (Онгоинги)"} - - {id: 347, cat: TV/Anime, desc: " |- Аниме Сериалы"} - - {id: 348, cat: TV/Anime, desc: " |- Аниме Сериалы (HD)"} - - {id: 365, cat: TV/Anime, desc: " |- OVA"} - - {id: 361, cat: Movies/Other, desc: "Фильмы"} - - {id: 366, cat: Movies/Other, desc: " |- Новинки"} - - {id: 364, cat: Movies/Other, desc: " |- Аниме Фильмы (HD)"} - - {id: 362, cat: Movies/Other, desc: " |- OVA"} - # Музыка # Music - - {id: 225, cat: Audio/MP3, desc: "♫ Российские исполнители ♫ (Mp3)♫"} - - {id: 228, cat: Audio/MP3, desc: " |- Поп музыка России и стран бывшего СССР"} - - {id: 229, cat: Audio/MP3, desc: " |- Русские поп сборники"} - - {id: 226, cat: Audio/MP3, desc: " |- Русский шансон и авторская песня"} - - {id: 308, cat: Audio/MP3, desc: " |- Военная песня"} - - {id: 227, cat: Audio/MP3, desc: " |- Сборники Русского шансона"} - - {id: 230, cat: Audio/MP3, desc: " |- Русский Rap, Hip-Hop"} - - {id: 450, cat: Audio/MP3, desc: " |- Российский джаз и блюз"} - - {id: 343, cat: Audio/MP3, desc: " |- 🎵 МУЗЫКА ИИ и НЕЙРОСЕТЕЙ (MP3, FLAC)"} - - {id: 122, cat: Audio/MP3, desc: "♫ Зарубежная музыка (Mp3)"} - - {id: 315, cat: Audio/MP3, desc: " |- Зарубежные Сборники DISCO, ITALO-DISCO"} - - {id: 239, cat: Audio/MP3, desc: " |- Зарубежная поп музыка (альбомы, дискографии)"} - - {id: 128, cat: Audio/MP3, desc: " |- Зарубежные сборники поп музыки"} - - {id: 244, cat: Audio/MP3, desc: " |- Зарубежный Chanson"} - - {id: 131, cat: Audio/MP3, desc: " |- Джаз и блюз"} - - {id: 245, cat: Audio/MP3, desc: " |- Инструментальная музыка"} - - {id: 129, cat: Audio/MP3, desc: " |- Рэп, Хип-Хоп, R'n'B"} - - {id: 126, cat: Audio/MP3, desc: " |- Reggae, Ska, Dub"} - - {id: 132, cat: Audio/MP3, desc: " |- Классическая музыка"} - - {id: 125, cat: Audio/MP3, desc: " |- New Age, Relax, Meditative & Flamenco"} - - {id: 124, cat: Audio/MP3, desc: " |- Фольклор, Народная и Этническая музыка"} - - {id: 338, cat: Audio/MP3, desc: " |- Country"} - - {id: 231, cat: Audio/MP3, desc: " |- 🎵 Сборники и альбомы выходившие неофициальными изданиями."} - - {id: 201, cat: Audio/MP3, desc: " |- Русский Rock, Metal (mp3)"} - - {id: 202, cat: Audio/MP3, desc: " |- Сборники Русского рока (mp3)"} - - {id: 156, cat: Audio/MP3, desc: " |- Classic & Hard Rock"} - - {id: 334, cat: Audio/MP3, desc: " |- Progressive & Art-Rock"} - - {id: 213, cat: Audio/MP3, desc: " |- Melodic & Folk Rock"} - - {id: 214, cat: Audio/MP3, desc: " |- Soft & Pop-Rock"} - - {id: 212, cat: Audio/MP3, desc: " |- Instrumental & Guitar Rock"} - - {id: 241, cat: Audio/MP3, desc: " |- Rock'n'Roll, Rockabilly, Psychobilly"} - - {id: 211, cat: Audio/MP3, desc: " |- Сборники зарубежного Рока"} - - {id: 394, cat: Audio/MP3, desc: " |- Зарубежный Metal (альбомы, дискографии)"} - - {id: 154, cat: Audio/MP3, desc: " |- Зарубежный Metal (сборники)"} - - {id: 216, cat: Audio/MP3, desc: "♫ Alternative, Punk, Industrial, Gothic ♫ (Mp3)♫"} - - {id: 219, cat: Audio/MP3, desc: " |- Alternative"} - - {id: 220, cat: Audio/MP3, desc: " |- Punk"} - - {id: 217, cat: Audio/MP3, desc: " |- Hardcore"} - - {id: 309, cat: Audio/MP3, desc: " |- Indie Rock, Indie Pop"} - - {id: 310, cat: Audio/MP3, desc: " |- Post-Rock, Post-Punk"} - - {id: 218, cat: Audio/MP3, desc: " |- Industrial"} - - {id: 311, cat: Audio/MP3, desc: " |- Metalcore, Post-hardcore"} - - {id: 221, cat: Audio/MP3, desc: " |- Gothic Rock"} - - {id: 292, cat: Audio/MP3, desc: "♫ Электронная музыка ♫(Mp3)♫"} - - {id: 298, cat: Audio/MP3, desc: " |- Trance, Psy-Trance"} - - {id: 318, cat: Audio/MP3, desc: " |- Hardcore, Hardstyle, Jumpstyle"} - - {id: 296, cat: Audio/MP3, desc: " |- House"} - - {id: 297, cat: Audio/MP3, desc: " |- Techno"} - - {id: 299, cat: Audio/MP3, desc: " |- Drum & Bass, Breakbeat"} - - {id: 301, cat: Audio/MP3, desc: " |- Synthpop"} - - {id: 302, cat: Audio/MP3, desc: " |- Downtempo, Chillout"} - - {id: 300, cat: Audio/MP3, desc: " |- EBM, Industrial"} - - {id: 303, cat: Audio/MP3, desc: " |- Остальные жанры и направления"} - - {id: 234, cat: Audio/Lossless, desc: "♫ HI-RES Audio ♫"} - - {id: 312, cat: Audio/Lossless, desc: " |- ♫ Classics in processing (Hi-Res)"} - - {id: 235, cat: Audio/Lossless, desc: " |- ♫ Поп-музыка Hi-Res"} - - {id: 238, cat: Audio/Lossless, desc: " |- ♫ Электронная музыка Hi-Res"} - - {id: 237, cat: Audio/Lossless, desc: " |- ♫ Рок-музыка Hi-Res"} - - {id: 236, cat: Audio/Lossless, desc: " |- ♫ Джаз и Блюз Hi-Res"} - - {id: 324, cat: Audio, desc: "Оцифровки с аналоговых носителей"} - - {id: 330, cat: Audio, desc: " |- ♫ Зарубежная поп музыка (оцифровки)"} - - {id: 328, cat: Audio, desc: " |- ♫ Советская и Российская эстрада (оцифровки)"} - - {id: 453, cat: Audio, desc: " |- ♫ Шансон и авторская песня (оцифровки"} - - {id: 455, cat: Audio, desc: " |- ♫ Зарубежный и Отечественный Metal (оцифровки)"} - - {id: 331, cat: Audio, desc: " |- ♫ Русский рок (оцифровки)"} - - {id: 326, cat: Audio, desc: " |- ♫ Rock (оцифровки)"} - - {id: 325, cat: Audio, desc: " |- ♫ Электронная музыка (оцифрофки)"} - - {id: 454, cat: Audio, desc: " |- ♫ Джаз и блюз (оцифровки)"} - - {id: 452, cat: Audio, desc: " |- ♫ Classical (оцифровки)"} - - {id: 451, cat: Audio, desc: " |- ♫ Easy Listening (оцифровки)"} - - {id: 133, cat: Audio/Lossless, desc: "♫ Музыка в формате ♫ (lossless)"} - - {id: 203, cat: Audio/Lossless, desc: " |- Музыка России и стран бывшего СССР (lossless)"} - - {id: 139, cat: Audio/Lossless, desc: " |- Поп музыка, Eurodance, Disco (lossless)"} - - {id: 339, cat: Audio/Lossless, desc: " |- Rock, Metal (Lossless)"} - - {id: 340, cat: Audio/Lossless, desc: " |- Русский Rock, Metal (Lossless)"} - - {id: 141, cat: Audio/Lossless, desc: " |- Электронная музыка (lossless)"} - - {id: 142, cat: Audio/Lossless, desc: " |- Джаз и блюз (lossless)"} - - {id: 138, cat: Audio/Lossless, desc: " |- Шансон и Военная песня (lossless)"} - - {id: 157, cat: Audio/Video, desc: "Музыкальные видеоклипы и концерты"} - - {id: 161, cat: Audio/Video, desc: " |- Поп"} - - {id: 160, cat: Audio/Video, desc: " |- Электронная музыка"} - - {id: 159, cat: Audio/Video, desc: " |- Рэп, Хип-Хоп, R'n'B"} - - {id: 295, cat: Audio/Video, desc: " |- Рок музыка (Video)"} - - {id: 294, cat: Audio/Video, desc: " |- Музыкальные шоу"} - - {id: 158, cat: Audio/Video, desc: " |- Другие направления"} - # Литература # Literature - - {id: 166, cat: Books, desc: "Книги FB2"} - - {id: 173, cat: Books, desc: " |- Художественная литература"} - - {id: 314, cat: Books, desc: " |- Учебно-техническая литература"} - - {id: 434, cat: Books/Mags, desc: "Журналы"} - - {id: 435, cat: Books/Mags, desc: " |- Авто-мото"} - - {id: 437, cat: Books/Mags, desc: " |- Строительство и ремонт"} - - {id: 441, cat: Books/Mags, desc: " |- Сад и огород"} - - {id: 436, cat: Books/Mags, desc: " |- Кулинория"} - - {id: 442, cat: Books/Mags, desc: " |- Наука"} - - {id: 440, cat: Books/Mags, desc: " |- Космос"} - - {id: 439, cat: Books/Mags, desc: " |- История"} - - {id: 438, cat: Books/Mags, desc: " |- Хобби"} - - {id: 167, cat: Audio/Audiobook, desc: "Аудиокниги"} - - {id: 335, cat: Audio/Audiobook, desc: " |- ♫ Русская литература ХХ-ХХI века"} - - {id: 232, cat: Audio/Audiobook, desc: " |- ♫ Детективы, приключения, триллеры"} - - {id: 337, cat: Audio/Audiobook, desc: " |- ♫ Детективы Дарьи Донцовой"} - - {id: 210, cat: Audio/Audiobook, desc: " |- ♫ Зарубежные авторы. Романы, повести, рассказы"} - - {id: 233, cat: Audio/Audiobook, desc: " |- ♫ Любовные романы"} - - {id: 176, cat: Audio/Audiobook, desc: " |- ♫ Мистика, фантастика, ужасы"} - - {id: 175, cat: Audio/Audiobook, desc: " |- ♫ Любовно-фантастический роман"} - - {id: 174, cat: Audio/Audiobook, desc: " |- Аудиокниги на других языках (Audio)"} - - {id: 319, cat: Audio/Audiobook, desc: " |- Аудиокниги для детей"} - - {id: 350, cat: Books, desc: "Медицина и здоровье"} - - {id: 351, cat: Books, desc: " |- Клиническая медицина до 1980 года"} - - {id: 352, cat: Books, desc: " |- Клиническая медицина с 1980 по 2000 год"} - - {id: 353, cat: Books, desc: " |- Клиническая медицина после 2000 года"} - - {id: 354, cat: Books/Mags, desc: " |- Научная медицинская периодика (газеты и журналы)"} - - {id: 357, cat: Books/Mags, desc: " |- Популярная медицинская периодика (газеты и журналы)"} - - {id: 355, cat: Books, desc: " |- Медико-биологические науки"} - - {id: 356, cat: Books, desc: " |- Фармация и фармакология"} - - {id: 358, cat: Books, desc: " |- Нетрадиционная, народная медицина и популярные книг..."} - - {id: 360, cat: Books, desc: " |- Видеоуроки и лекции по медицине"} - - {id: 359, cat: Books, desc: " |- Ветеринария, разное"} - # Игры # Games - - {id: 105, cat: PC/Games, desc: "Игры для ПК"} - - {id: 120, cat: PC/Games, desc: " |- Горячие новинки (License`s)"} - - {id: 119, cat: PC/Games, desc: " |- Горячие новинки (Repack`s)"} - - {id: 118, cat: PC/Games, desc: " |- Early Access, Demo, Alpha, Beta версии"} - - {id: 117, cat: PC/Games, desc: " |- Action"} - - {id: 116, cat: PC/Games, desc: " |- RPG"} - - {id: 289, cat: PC/Games, desc: " |- Horror"} - - {id: 307, cat: PC/Games, desc: " |- Logic"} - - {id: 304, cat: PC/Games, desc: " |- Lifestyle"} - - {id: 336, cat: PC/Games, desc: " |- Sports"} - - {id: 306, cat: PC/Games, desc: " |- Exploration"} - - {id: 305, cat: PC/Games, desc: " |- Management"} - - {id: 115, cat: PC/Games, desc: " |- Аркады"} - - {id: 114, cat: PC/Games, desc: " |- Стратегии"} - - {id: 113, cat: PC/Games, desc: " |- Гоночные симуляторы"} - - {id: 112, cat: PC/Games, desc: " |- Другие симуляторы"} - - {id: 111, cat: PC/Games, desc: " |- Приключения и квесты"} - - {id: 110, cat: PC/Games, desc: " |- Мини-игры"} - - {id: 109, cat: PC/Games, desc: " |- Многопользовательские и онлайн игры"} - - {id: 108, cat: PC/Games, desc: " |- Старые игры"} - - {id: 107, cat: PC/Games, desc: " |- Игровое видео"} - - {id: 106, cat: PC/Games, desc: " |- Патчи, моды, дополнения, DLC и т.п."} - - {id: 414, cat: Console/PS4, desc: " |- PS4 / PS5"} - - {id: 402, cat: Console/PS3, desc: " |- PS/PS2/PS3"} - - {id: 415, cat: Console/PSP, desc: " |- PSP / Игры PS1 для PSP"} - - {id: 431, cat: Console/Other, desc: " |- Switch"} - - {id: 416, cat: Console/XBox 360, desc: " |- Xbox 360"} - - {id: 417, cat: PC/Games, desc: " |- SteamMachine"} - # Софт # Software - - {id: 76, cat: PC/ISO, desc: "Операционные системы"} - - {id: 80, cat: PC/ISO, desc: " |- Windows 11"} - - {id: 79, cat: PC/ISO, desc: " |- Windows 10"} - - {id: 78, cat: PC/ISO, desc: " |- Windows 8.x"} - - {id: 77, cat: PC/ISO, desc: " |- Windows 7"} - - {id: 250, cat: PC/ISO, desc: " |- Windows Vista , Windows XP , Windows выпущенные до 20..."} - - {id: 81, cat: PC/ISO, desc: " |- Серверные ОС от Microsoft"} - - {id: 83, cat: PC/ISO, desc: " |- Linux, Unix, Mac и другие ОС"} - - {id: 82, cat: PC/ISO, desc: " |- Разное (RC, Beta, Service Packs, Cracks)"} - - {id: 93, cat: PC/0day, desc: "Системные программы"} - - {id: 86, cat: PC/0day, desc: " |- Драйверы и ресурсные библиотеки"} - - {id: 251, cat: PC/0day, desc: " |- Обслуживание и диагностика компьютера"} - - {id: 252, cat: PC/0day, desc: " |- Работа с жёсткими дисками"} - - {id: 253, cat: PC/0day, desc: " |- Работа с внешними носителями информации"} - - {id: 254, cat: PC/0day, desc: " |- Архиваторы и файловые менеджеры"} - - {id: 91, cat: PC/0day, desc: " |- Программы для интернета и сетей"} - - {id: 256, cat: PC/0day, desc: " |- Настройка и оптимизация, изменение интерфейса ОС"} - - {id: 257, cat: PC/0day, desc: " |- WPI и прочие сборники программ"} - - {id: 258, cat: PC/0day, desc: " |- LiveCD/DVD/USB"} - - {id: 92, cat: PC/0day, desc: "Безопасность"} - - {id: 259, cat: PC/0day, desc: " |- Антивирусы, брандмауэры"} - - {id: 260, cat: PC/0day, desc: " |- Антишпионское, антирекламное и антиблокировочное ПО"} - - {id: 261, cat: PC/0day, desc: " |- Средства шифрования и защиты информации"} - - {id: 262, cat: PC/0day, desc: " |- Комплексная защита ПК"} - - {id: 263, cat: PC/0day, desc: "Программы для бизнеса, офиса, научной и проектной работы"} - - {id: 272, cat: PC/0day, desc: " |- Офисные системы"} - - {id: 271, cat: PC/0day, desc: " |- Системы для бизнеса"} - - {id: 270, cat: PC/0day, desc: " |- Системы для научной работы"} - - {id: 269, cat: PC/0day, desc: " |- САПР, софт для архитекторов, дизайнеров и пр."} - - {id: 268, cat: PC/0day, desc: " |- Справочные системы, базы данных"} - - {id: 267, cat: PC/0day, desc: " |- Программы для верстки, печати и работы со шрифтами"} - - {id: 266, cat: PC/0day, desc: " |- Работа с PDF, DjVu, FB2, EPUB, MOBI и пр."} - - {id: 265, cat: PC/0day, desc: " |- Словари, переводчики, программы обучения языкам"} - - {id: 264, cat: PC/0day, desc: " |- Распознавание текста, звука и синтез речи"} - - {id: 273, cat: PC/0day, desc: "Программирование и веб-разработка"} - - {id: 89, cat: PC/0day, desc: " |- Среды программирования, компиляторы и вспомогател..."} - - {id: 276, cat: PC/0day, desc: " |- Текстовые редакторы с подсветкой кода"} - - {id: 275, cat: PC/0day, desc: " |- Системы управления базами данных (СУБД)"} - - {id: 277, cat: PC/0day, desc: " |- WYSIWYG-редакторы для веб-дизайна"} - - {id: 274, cat: PC/0day, desc: " |- Скрипты и шаблоны"} - - {id: 278, cat: PC/0day, desc: "Графика и мультимедиа"} - - {id: 316, cat: PC/0day, desc: " |- 3D моделирование, рендеринг и плагины для них"} - - {id: 287, cat: PC/0day, desc: " |- Графические редакторы и каталогизаторы"} - - {id: 288, cat: PC/0day, desc: " |- Графические грабберы и конвертеры"} - - {id: 286, cat: PC/0day, desc: " |- Аудио- Видео- редакторы"} - - {id: 284, cat: PC/0day, desc: " |- Аудио- Видео- проигрыватели и каталогизаторы"} - - {id: 283, cat: PC/0day, desc: " |- Аудио- Видео- грабберы, рипперы и конвертеры"} - - {id: 282, cat: PC/0day, desc: " |- Анимация"} - - {id: 281, cat: PC/0day, desc: " |- Программные комплекты"} - - {id: 279, cat: PC/0day, desc: " |- Разное (Графика и мультимедиа)"} - - {id: 332, cat: PC/0day, desc: "Системы навигации и карты"} - - {id: 94, cat: PC/0day, desc: " |- Утилиты"} - - {id: 88, cat: PC/0day, desc: " |- Мультимедия"} - # Графика и Дизайн # Graphics and Design - - {id: 178, cat: Other, desc: "Графические изображения"} - - {id: 184, cat: Other, desc: " |- Обои"} - - {id: 183, cat: Other, desc: " |- Картинки"} - - {id: 182, cat: Other, desc: " |- Фотографии"} - - {id: 181, cat: Other, desc: " |- Обложки"} - - {id: 180, cat: Other, desc: " |- Аватарки, Иконки и пр."} - - {id: 179, cat: Other, desc: "Дизайнерские материалы"} - - {id: 186, cat: Other, desc: " |- Всё для 2d графики"} - - {id: 185, cat: Other, desc: " |- Всё для 3d графики"} - # Мобильные устройства # Mobile devices - - {id: 376, cat: PC/Mobile-Other, desc: "Помощь по разделу (Мобильные устройства)"} - - {id: 375, cat: PC/Mobile-Other, desc: "Игры"} - - {id: 393, cat: PC/Mobile-Other, desc: " |- Java"} - - {id: 390, cat: PC/Mobile-Other, desc: " |- Windows Mobile / Windows Phone"} - - {id: 387, cat: PC/Mobile-iOS, desc: " |- iOS"} - - {id: 383, cat: PC/Mobile-Android, desc: " |- Android"} - - {id: 380, cat: PC/Mobile-Other, desc: " |- Symbian"} - - {id: 374, cat: PC/Mobile-Other, desc: "Приложения"} - - {id: 392, cat: PC/Mobile-Other, desc: " |- Java"} - - {id: 389, cat: PC/Mobile-Other, desc: " |- Windows Mobile / Windows Phone"} - - {id: 386, cat: PC/Mobile-iOS, desc: " |- iOS"} - - {id: 382, cat: PC/Mobile-Android, desc: " |- Android"} - - {id: 379, cat: PC/Mobile-Other, desc: " |- Symbian"} - - {id: 367, cat: PC/Mobile-Other, desc: "Дополнительно"} - - {id: 373, cat: PC/Mobile-Other, desc: " |- Прошивки"} - - {id: 368, cat: PC/Mobile-Other, desc: " |- Софт для работы с телефоном"} - - {id: 370, cat: PC/Mobile-Other, desc: " |- Видео для КПК"} - - {id: 369, cat: PC/Mobile-Other, desc: " |- Темы и Обои"} - - {id: 426, cat: PC/Mobile-Other, desc: " |- Подготовка и оформления раздач R.G. Mobile"} - # TorrentPier - - {id: 205, cat: Other, desc: "Ресурсы для TorrentPier"} - - {id: 209, cat: Other, desc: " |- Модификации для TorrentPier"} - - {id: 207, cat: Other, desc: " |- Графика и стили для TorrentPier"} - - {id: 206, cat: Other, desc: " |- Шаблоны для TorrentPier"} - - {id: 449, cat: Other, desc: " |- Ресурсы для скачивания"} - # Технический форум # Technical Forum - - {id: 163, cat: Other, desc: "Временные раздачи"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: stripcyrillic - type: checkbox - label: Strip Cyrillic Letters - default: false - - name: addrussiantotitle - type: checkbox - label: Add RUS to end of all titles to improve language detection by Sonarr and Radarr. Will cause English-only results to be misidentified. - default: false - - name: sort - type: select - label: Sort requested from site - default: 1 - options: - 1: created - 10: seeders - 7: size - 2: title - - name: type - type: select - label: Order requested from site - default: 2 - options: - 2: desc - 1: asc - -search: - paths: - - path: tracker.php - inputs: - $raw: "{{ if .Categories }}{{ range .Categories }}f[]={{.}}&{{end}}{{ else }}f[]=-1{{ end }}" - prev_allw: 0 - prev_a: 0 - prev_dla: 0 - prev_dlc: 0 - prev_dld: 0 - prev_dlw: 0 - prev_my: 0 - prev_new: 0 - prev_sd: 0 - prev_da: 1 - prev_dc: 0 - prev_df: 1 - prev_ds: 0 - prev_tor_type: 0 - # sort by 1=registered, 2=topicTitle, 4=completed, 10=seed, 11=leech, 12=up, 13=down, 5=replies, 6=views, 7=size, 8=lastPost, 9=seedLastSeen - o: "{{ .Config.sort }}" - # 1=asc, 2=desc - s: "{{ .Config.type }}" - # -1=all time - tm: -1 - # seed not seen -1=disregard - sns: -1 - # group releases -1=not selected - srg: -1 - # show category - dc: 0 - # show forum - df: 1 - # show author - da: 1 - # show speed - ds: 0 - nm: "{{ .Keywords }}" - # search by partial word - allw: 0 - - keywordsfilters: - - name: diacritics - args: replace - - name: re_replace # S01 to сезон 1 - args: ["(?i)\\bS0*(\\d+)\\b", "сезон $1"] - - name: re_replace # E02 to сери 1 - args: ["(?i)\\bE0*(\\d+)\\b", "сери $1"] - - name: re_replace # S01E02 to сезон 1 сери 2 - args: ["(?i)\\bS0*(\\d+)E0*(\\d+)\\b", "сезон $1 сери $2"] - -# headers: -# Cookie: ["godbayadblock=godbayadblock"] - - rows: - selector: tr[id^="tor_"]:has(a[href^="./dl.php?id="]), tr[id^="tor_"]:has(a[href^="magnet:?xt="]) - - fields: - category: - selector: a[href^="tracker.php?f="] - attribute: href - filters: - - name: querystring - args: f - title: - selector: a[href^="./viewtopic.php?t="] - filters: - # normalize to SXXEYY format - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))?", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)\\s*(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "S$1E$2 of $3"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "S$1E$2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*[CС]езоны?.+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?).+?(\\d+(?:-\\d+)?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "S$1E$2"] - - name: re_replace - args: ["(?i)[CС]езоны?[\\s:]*(\\d+(?:-\\d+)?)", "S$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+[CС]езоны?", "S$1"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s*из\\s*(\\w?)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1 of $2"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))\\s+из\\s*(\\w?)", "E$1 of $2"] - - name: re_replace - args: ["(?i)(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))[\\s:]*(\\d+(?:-\\d+)?)", "E$1"] - - name: re_replace - args: ["(?i)(\\d+(?:-\\d+)?)\\s+(?:\\s*(?:[CС]ери[ияй]|Эпизод|Выпуски?))", "E$1"] - - name: re_replace - args: ["(\\([\\p{IsCyrillic}\\W]+\\))|(^[\\p{IsCyrillic}\\W\\d]+\\/ )|([\\p{IsCyrillic} \\-]+,+)|([\\p{IsCyrillic}]+)", "{{ if .Config.stripcyrillic }}{{ else }}$1$2$3$4{{ end }}"] - - name: re_replace - args: ["(?i)\\bHDTV[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bSAT[-\\s]?Rip\\b", "HDTV"] - - name: re_replace - args: ["(?i)\\bWEB[-\\s]?DL[-\\s]?Rip\\b", "WEB-DL"] - - name: re_replace - args: ["(?i)\\bWEB\\sRip\\b", "WEBRip"] - - name: re_replace - args: ["(?i)\\bWEB\\sDL\\b", "WEB-DL"] - - name: re_replace - args: ["[\\[\\(\\{<«][\\s\\W]*[\\]\\)\\}>»]", ""] - - name: re_replace - args: ["^[\\s&,\\.!\\?\\+\\-_\\|\\/':]+", ""] - - name: append - args: "{{ if .Config.addrussiantotitle }} RUS{{ else }}{{ end }}" - details: - selector: a[href^="./viewtopic.php?t="] - attribute: href - download: - selector: a[href^="./dl.php?id="] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - size: - selector: td:nth-child(6) > u - seeders: - selector: td.seedmed > b - leechers: - selector: td.leechmed > b - grabs: - selector: td:nth-child(9) - date: - # unix - selector: td:last-child > u - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 - description: - selector: a[href^="./viewtopic.php?t="] -# TorrentPier v2.4.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vault-network.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vault-network.yml deleted file mode 100644 index 78edf826..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vault-network.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: vault-network -name: Vault network -description: "Vault network is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -certificates: - - b0d73bb07c3b4cc1f8eb9231a4443e34e876d79e # self-signed. -links: - - https://vault-network.net/ - -caps: - categorymappings: - - {id: 5, cat: PC/Mobile-Android, desc: "Apps Android"} - - {id: 4, cat: PC/Mac, desc: "Apps Mac"} - - {id: 3, cat: PC/0day, desc: "Apps Windows"} - - {id: 35, cat: Audio, desc: "Audio Boxsets"} - - {id: 33, cat: Audio, desc: "Audio Discography"} - - {id: 18, cat: Audio, desc: "Audio DMC"} - - {id: 15, cat: Audio/Lossless, desc: "Audio flac"} - - {id: 19, cat: Audio, desc: "Audio Mastermix"} - - {id: 14, cat: Audio/MP3, desc: "Audio mp3"} - - {id: 16, cat: Audio, desc: "Audio Packs / Weekly Packs"} - - {id: 17, cat: Audio/Video, desc: "Audio Video"} - - {id: 31, cat: Audio/Lossless, desc: "Audio WAV"} - - {id: 23, cat: Audio/Audiobook, desc: "Books Audio Books"} - - {id: 24, cat: Books/EBook, desc: "Books eBooks"} - - {id: 25, cat: Books/Mags, desc: "Books Magazines"} - - {id: 37, cat: Other, desc: "Christmas"} - - {id: 38, cat: Audio, desc: "Christmas Audio"} - - {id: 39, cat: Movies, desc: "Christmas Movie"} - - {id: 40, cat: TV, desc: "Christmas TV"} - - {id: 29, cat: Console, desc: "Games"} - - {id: 42, cat: PC/Games, desc: "Games PC"} - - {id: 43, cat: Console/Wii, desc: "Games Wii"} - - {id: 28, cat: Audio, desc: "Karaoke"} - - {id: 7, cat: Movies/HD, desc: "Movie 1080p"} - - {id: 6, cat: Movies/UHD, desc: "Movie 4K"} - - {id: 8, cat: Movies/HD, desc: "Movie 720p"} - - {id: 11, cat: Movies, desc: "Movie Animated"} - - {id: 12, cat: Movies, desc: "Movie Boxsets"} - - {id: 10, cat: Movies/SD, desc: "Movie Cam/TS"} - - {id: 13, cat: TV/Documentary, desc: "Movie Documentaries"} - - {id: 9, cat: Movies/SD, desc: "Movie SD"} - - {id: 30, cat: TV/Sport, desc: "Sport"} - - {id: 21, cat: TV/Documentary, desc: "TV Documentaries"} - - {id: 20, cat: TV, desc: "TV Episodes"} - - {id: 22, cat: TV, desc: "TV Packs"} - - {id: 27, cat: Other, desc: "VIP"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Filter freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 40 on your account profile. The default is 20. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logout: "" - error: - - selector: table:contains("error has occured") - test: - path: browse.php - selector: a[href*="/logout.php"] - -download: - before: - path: takethanks.php - method: post - inputs: - torrentid: "{{ .DownloadUri.Query.id }}" - selectors: - - selector: a[href*="download.php?id="] - attribute: href - -search: - paths: - - path: browse.php - keywordsfilters: - - name: re_replace - args: ["[\\s]+", "%"] - inputs: - do: search - keywords: "{{ .Keywords }}" - # t_name, t_description, t_both, t_uploader, t_genre - search_type: t_name - category: 0 # multi cat search not supported - # yes, no - include_dead_torrents: yes - sort: "{{ if .Config.freeleech }}free{{ else }}{{ .Config.sort }}{{ end }}" - order: "{{ if .Config.freeleech }}asc{{ else }}{{ .Config.type }}{{ end }}" - - rows: - selector: "table.sortable tr:has(a[href*=\"/download.php?id=\"]){{ if .Config.freeleech }}:has(img[src$=\"/freedownload.gif\"]){{ else }}{{ end }}" - - fields: - category: - selector: a[href*="/browse.php?category="] - attribute: href - filters: - - name: querystring - args: category - title_default: - selector: a[href*="/details.php?id="] - title: - selector: div.tooltip-content > div - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="/details.php?id="] - attribute: href - download: - selector: a[href*="/details.php?id="] - attribute: href - poster: - selector: div.tooltip-content > img - attribute: src - date: - selector: td:nth-child(2) - # auto adjusted by site account profile - filters: - - name: regexp - args: "(\\d{2}-\\d{2}-\\d{4} \\d{2}:\\d{2})" - - name: dateparse - args: "dd-MM-yyyy HH:mm" - size: - selector: td:nth-child(5) - grabs: - selector: td:nth-child(6) - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src$="freedownload.gif"]: 0 - img[src$="silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src$="x2.gif"]: 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# TS Special Edition v.8.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vietmediaf.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vietmediaf.yml deleted file mode 100644 index 8675429b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vietmediaf.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: vietmediaf -name: VietMediaF -description: "VietMediaF is a VIETNAMESE Private Torrent Tracker for MOVIES / TV" -language: vi-VN -type: private -encoding: UTF-8 -links: - - https://tracker.vietmediaf.store/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your VietMediaF account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.4 but torrents must be seeded for 7 days regardless of ratio -# minimumratio: -# text: 0.4 - minimumseedtime: - # 7 days (as seconds = 7 x 24 x 60 x 60) - text: 604800 -# json UNIT3D 9.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vsthouse.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vsthouse.yml deleted file mode 100644 index ef7e62c8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vsthouse.yml +++ /dev/null @@ -1,74 +0,0 @@ ---- -id: vsthouse -name: VSTHouse -description: "VSTHouse is a RUSSIAN Public site for AUDIO apps, plugins and samples" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://vsthouse.ru/ -legacylinks: - - http://vsthouse.ru/ - -caps: - categories: - PC: PC - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: a[title^="Скачать:"] - attribute: href - -search: - paths: - # http://vsthouse.ru/search/?q=drummer&m=load&t=0 - - path: search - inputs: - q: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - m: load - t: 0 - - rows: - selector: table.eBlock:has(div.eDetails:contains(":")) - filters: - - name: andmatch - - fields: - category: - text: PC - title: - selector: div.eTitle a - details: - selector: div.eTitle a - attribute: href - download: - selector: div.eTitle a - attribute: href - description: - selector: div.eDetails - date: - selector: div.eDetails - remove: a, span - filters: - - name: replace - args: ["- ", ""] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine uCoz diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vstorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vstorrent.yml deleted file mode 100644 index 18729b0e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vstorrent.yml +++ /dev/null @@ -1,80 +0,0 @@ ---- -id: vstorrent -name: VSTorrent -description: "VSTorrent is a Public site for AUDIO apps, plugins and samples" -language: en-US -type: public -encoding: UTF-8 -links: - - https://vstorrent.org/ - -caps: - categories: - PC: PC - - modes: - search: [q] - -settings: - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - relevance: relevance - - name: type - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - -download: - selectors: - - selector: a[href^="magnet:?xt"] - attribute: href - -search: - # https://vstorrent.org/?s=&orderby=date&order=DESC&post_type=post - paths: - - path: / - allowEmptyInputs: true - inputs: - s: "{{ .Keywords }}" - orderby: "{{ .Config.sort }}" - order: "{{ .Config.type }}" - post_type: post - - rows: - selector: article:has(a) - filters: - - name: andmatch - - fields: - category: - text: PC - title: - selector: .entry-title - details: - selector: .entry-title > a - attribute: href - download: - selector: .entry-title > a - attribute: href - description: - selector: .entry-summary - date: - selector: span.date - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# wordpress 7.2.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vsttorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vsttorrents.yml deleted file mode 100644 index abb929d2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/vsttorrents.yml +++ /dev/null @@ -1,74 +0,0 @@ ---- -id: vsttorrents -name: VST Torrentz -description: "VST Torrentz is a Public site for AUDIO apps, plugins and samples" -language: en-US -type: public -encoding: UTF-8 -links: - - https://vsttorrentz.net/ -legacylinks: - - https://vsttorrents.net/ - - https://looptorrent.net/ - -caps: - categories: - PC: PC - - modes: - search: [q] - -settings: [] - -download: - selectors: - - selector: div.wp-block-file a - attribute: href - -search: - paths: - - path: / - inputs: - s: "{{ .Keywords }}" - - rows: - selector: article:has(.entry-title) - filters: - - name: andmatch - - fields: - category: - text: PC - title: - selector: .entry-title - details: - selector: .entry-title > a - attribute: href - download: - selector: .entry-title > a - attribute: href - poster: - selector: .wp-post-image - attribute: data-src - description: - selector: .entry-summary - date_year: - selector: .post-date - optional: true - date_time: - selector: time - attribute: datetime - optional: true - date: - text: "{{ if or .Result.date_year .Result.date_time }}{{ or .Result.date_year .Result.date_time }}{{ else }}now{{ end }}" - size: - text: "512 MB" - seeders: - text: 1 - leechers: - text: 1 - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# wordpress 6.7.2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/whiteangel.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/whiteangel.yml deleted file mode 100644 index 4aa5889e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/whiteangel.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: whiteangel -name: White Angel -description: "White Angel is a HUNGARIAN Private Tracker for MOVIES / TV" -language: hu-HU -type: private -encoding: ISO-8859-2 -links: - - https://white-angel.hu/ - -caps: - categorymappings: - - {id: 39, cat: Books/EBook, desc: "E-book"} - - {id: 53, cat: XXX, desc: "Film (XXX)"} - - {id: 74, cat: TV/Anime, desc: "Film (Animáció cam)"} - - {id: 72, cat: TV/Anime, desc: "Film (Animáció)"} - - {id: 44, cat: Movies/SD, desc: "Film (Cam Hun)"} - - {id: 40, cat: Movies/DVD, desc: "Film (DVD Eng)"} - - {id: 41, cat: Movies/DVD, desc: "Film (DVD Hun)"} - - {id: 57, cat: Movies/HD, desc: "Film (HD Eng)"} - - {id: 38, cat: Movies/HD, desc: "Film (HD Hun)"} - - {id: 42, cat: Movies/SD, desc: "Film (Xvid Eng)"} - - {id: 73, cat: Movies/SD, desc: "Film (Xvid Eng-Hun)"} - - {id: 43, cat: Movies/SD, desc: "Film (Xvid Hun)"} - - {id: 69, cat: XXX/x264, desc: "Film (XXX HD)"} - - {id: 63, cat: Movies/SD, desc: "Film (Cam Eng)"} - - {id: 71, cat: Audio/Audiobook, desc: "Hangos könyv"} - - {id: 45, cat: PC/Games, desc: "Játék ISO"} - - {id: 46, cat: Console/PS3, desc: "Játék Ps2"} - - {id: 66, cat: PC/Games, desc: "Játék Rip"} - - {id: 47, cat: Console/XBox, desc: "Játék Xbox"} - - {id: 70, cat: XXX/Other, desc: "Játék XXX"} - - {id: 48, cat: Other, desc: "Képek"} - - {id: 65, cat: TV/Anime, desc: "Mese Eng"} - - {id: 49, cat: TV/Anime, desc: "Mese Hun"} - - {id: 50, cat: PC/Mobile-Other, desc: "Mobil"} - - {id: 52, cat: TV, desc: "Sorozat (Hun Xvid)"} - - {id: 59, cat: TV, desc: "Sorozat (Xvid Eng)"} - - {id: 68, cat: PC/ISO, desc: "Win/Program/ISO"} - - {id: 51, cat: PC/0day, desc: "Win/Program/Rip"} - - {id: 54, cat: XXX/ImageSet, desc: "XXX/Kép"} - - {id: 55, cat: Audio, desc: "Zene/Eng"} - - {id: 56, cat: Audio, desc: "Zene/Hun"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - book-search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search FreeLeech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 1: title - 4: added - 5: size - 7: seeders - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrentek száma egy oldalon: setting to 100 on your account profile. - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table:contains("bejelentkezés nem sikerült") - test: - path: index.php - selector: a[href^="logout.php?k="] - -search: - paths: - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" # for dashboard imdbid search - # 0 active, 1 all, 2 deadonly, 3 freeleech, 4 x2, 5 myuploads, 6 requests, 7 seedbox - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # can search by genre but you need the id. &description=8 for Drama. - - rows: - selector: table[cellpadding="5"] tbody tr:has(a[href^="download.php?torrent="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - # can be abbreviated - selector: a[href^="javascript:torrent"] b - title: - # usually full length - selector: a[href^="javascript:torrent"] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - _id: - selector: a[href^="javascript:torrent"] - attribute: href - filters: - - name: regexp - args: (\d+) - details: - text: "details.php?id={{ .Result._id }}" - poster: - selector: a[onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: "src=(.*?) " - - name: prepend - args: "https:" - download: - selector: a[href^="download.php?torrent="] - attribute: href - genre: - selector: font[color="#C0C0C0"] - filters: - - name: replace - args: ["\xA0", ""] - description: - text: "{{ .Result.genre }}" - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - date: - selector: td:nth-last-child(5) - filters: - - name: append - args: " +01:00" # CET - - name: replace - args: ["\xA0", " "] - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - size: - selector: td:nth-last-child(4) - grabs: - selector: td:nth-last-child(3) - seeders: - selector: td:nth-last-child(2) - leechers: - selector: td:last-child - downloadvolumefactor: - case: - img[src="pic/ingyen.gif"]: 0 - "*": 1 - uploadvolumefactor: - case: - "font:contains(\"[x2]\")": 2 - "*": 1 - minimumratio: - text: 1.0 - minimumseedtime: - # 1 day (as seconds = 1 x 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/wihd.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/wihd.yml deleted file mode 100644 index 30cb25ba..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/wihd.yml +++ /dev/null @@ -1,235 +0,0 @@ ---- -id: wihd -name: World-In-HD -description: "World-In-HD is a Private Torrent Tracker. Your world in HD" -language: fr-FR -type: private -encoding: UTF-8 -links: - - https://world-in-hd.net/ - -caps: - # dont forget to update the search fields category (and size) case block(s) - categorymappings: - - {id: 565af82b1fd35761568b4572, cat: Movies/HD, desc: "1080p"} - - {id: 565af82b1fd35761568b4574, cat: Movies/HD, desc: "720p"} - - {id: 565af82b1fd35761568b4576, cat: Movies/HD, desc: "HDTV"} - - {id: 565af82b1fd35761568b4578, cat: Movies/HD, desc: "Bluray"} - - {id: 565af82b1fd35761568b457a, cat: Movies/HD, desc: "Bluray Remux"} - - {id: 565af82b1fd35761568b457c, cat: Movies/HD, desc: "Bluray 3D"} - - {id: 565af82d1fd35761568b4587, cat: TV/HD, desc: "1080p"} - - {id: 565af82d1fd35761568b4589, cat: TV/HD, desc: "720p"} - - {id: 565af82d1fd35761568b458b, cat: TV/HD, desc: "HDTV"} - - {id: 565af82d1fd35761568b458d, cat: TV/HD, desc: "Bluray"} - - {id: 565af82d1fd35761568b458f, cat: TV/HD, desc: "Bluray Remux"} - - {id: 565af82d1fd35761568b4591, cat: TV/HD, desc: "Bluray 3D"} - - {id: 565af82d1fd35761568b459c, cat: TV/Anime, desc: "1080p"} - - {id: 565af82d1fd35761568b459e, cat: TV/Anime, desc: "720p"} - - {id: 565af82d1fd35761568b45a0, cat: TV/Anime, desc: "HDTV"} - - {id: 565af82d1fd35761568b45a2, cat: TV/Anime, desc: "Bluray"} - - {id: 565af82d1fd35761568b45a4, cat: TV/Anime, desc: "Bluray Remux"} - - {id: 565af82d1fd35761568b45a6, cat: TV/Anime, desc: "Bluray 3D"} - - {id: 565af82d1fd35761568b45af, cat: PC/0day, desc: "software"} - - {id: 565af82d1fd35761568b45b1, cat: Audio/Video, desc: "clips"} - - {id: 565af82d1fd35761568b45b3, cat: Audio/Other, desc: "Audio tracks"} - - {id: 565af82d1fd35761568b45b5, cat: TV/Documentary, desc: "documentaries"} - - {id: 565af82d1fd35761568b45b7, cat: Movies/HD, desc: "Bluray"} - - {id: 59591f0807fd301b6eaa7a8f, cat: Movies/HD, desc: "1080p"} - - {id: 595cd82e07fd301b6eaa7a90, cat: Movies/HD, desc: "720p"} - - {id: 59e67c0ed5b6a3e689dd1e1f, cat: Movies/UHD, desc: "Bluray 4K"} - - {id: 59e488174a23a800358b4567, cat: Movies/UHD, desc: "Bluray Remux 4K"} - - {id: 5a64af02ee30983a7e596aed, cat: Movies/HD, desc: "WEB-DL"} - - {id: 5cd19d6c3fc7af3ea2491037, cat: Movies/UHD, desc: "2160p"} - - {id: 5cd1a01f3fc7af3ea2491038, cat: TV/Anime, desc: "2160p"} - - {id: 5cd1a0b73fc7af3ea2491039, cat: Movies/Other, desc: "2160p"} - - {id: 5cd19f753fc7af3ea249103a, cat: TV/UHD, desc: "2160p"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -login: - path: login - method: form - form: form#login-form - inputs: - _username: "{{ .Config.username }}" - _password: "{{ .Config.password }}" - _remember_me: on - error: - - selector: :contains("\"success\":false") - test: - path: torrents - selector: a[href*="logout"] - -search: - paths: - - path: "torrent/ajaxfiltertorrent/{{ if .Keywords }}{{ .Keywords }}{{ else }}null{{ end }}" - inputs: - $raw: "{{ range .Categories }}subcat[]={{.}}&{{end}}" - exclu: 0 - freeleech: "{{ if .Config.freeleech }}1{{ else }}0{{ end }}" - reseed: 0 - - rows: - selector: div.torrent-item - filters: - - name: andmatch - - fields: - title_phase1: - selector: a.torrentlink - attribute: title - filters: - - name: re_replace - args: ["(?i)(SEASON|SAISON) (\\d\\d)", "S$2"] - - name: re_replace - args: ["(?i)(SEASON|SAISON) (\\d)", "S0$2"] - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - poster: - selector: a.torrentlink > img.img-responsive - attribute: src - details: - selector: a.torrentlink - attribute: href - category: - selector: div.category - case: - ":contains(\"Films\"):contains(\"1080p\")": "565af82b1fd35761568b4572" - ":contains(\"Films\"):contains(\"720p\")": "565af82b1fd35761568b4574" - ":contains(\"Films\"):contains(\"HDTV\")": "565af82b1fd35761568b4576" - ":contains(\"Films\"):contains(\"Bluray Remux\")": "565af82b1fd35761568b457a" - ":contains(\"Films\"):contains(\"Bluray 3D\")": "565af82b1fd35761568b457c" - ":contains(\"Films\"):contains(\"Bluray Remux 4K\")": "59e488174a23a800358b4567" - ":contains(\"Films\"):contains(\"Bluray 4K\")": "59e67c0ed5b6a3e689dd1e1f" - ":contains(\"Films\"):contains(\"Bluray\")": "565af82b1fd35761568b4578" - ":contains(\"Films\"):contains(\"WEB-DL\")": "5a64af02ee30983a7e596aed" - ":contains(\"Séries\"):contains(\"1080p\")": "565af82d1fd35761568b4587" - ":contains(\"Séries\"):contains(\"720p\")": "565af82d1fd35761568b4589" - ":contains(\"Séries\"):contains(\"HDTV\")": "565af82d1fd35761568b458b" - ":contains(\"Séries\"):contains(\"Bluray Remux\")": "565af82d1fd35761568b458f" - ":contains(\"Séries\"):contains(\"Bluray 3D\")": "565af82d1fd35761568b4591" - ":contains(\"Séries\"):contains(\"Bluray\")": "565af82d1fd35761568b458d" - ":contains(\"Animations\"):contains(\"1080p\")": "565af82d1fd35761568b459c" - ":contains(\"Animations\"):contains(\"720p\")": "565af82d1fd35761568b459e" - ":contains(\"Animations\"):contains(\"HDTV\")": "565af82d1fd35761568b45a0" - ":contains(\"Animations\"):contains(\"Bluray Remux\")": "565af82d1fd35761568b45a4" - ":contains(\"Animations\"):contains(\"Bluray 3D\")": "565af82d1fd35761568b45a6" - ":contains(\"Animations\"):contains(\"Bluray\")": "565af82d1fd35761568b45a2" - ":contains(\"Divers\"):contains(\"Logiciels\")": "565af82d1fd35761568b45af" - ":contains(\"Divers\"):contains(\"Clips\")": "565af82d1fd35761568b45b1" - ":contains(\"Divers\"):contains(\"Pistes audios\")": "565af82d1fd35761568b45b3" - ":contains(\"Divers\"):contains(\"Documentaires\")": "565af82d1fd35761568b45b5" - ":contains(\"Divers\"):contains(\"Bluray\")": "565af82d1fd35761568b45b7" - ":contains(\"Divers\"):contains(\"1080p\")": "59591f0807fd301b6eaa7a8f" - ":contains(\"Divers\"):contains(\"720p\")": "595cd82e07fd301b6eaa7a90" - ":contains(\"Films\"):contains(\"2160p\")": "5cd19d6c3fc7af3ea2491037" - ":contains(\"Animations\"):contains(\"2160p\")": "5cd1a01f3fc7af3ea2491038" - ":contains(\"Divers\"):contains(\"2160p\")": "5cd1a0b73fc7af3ea2491039" - ":contains(\"Séries\"):contains(\"2160p\")": "5cd19f753fc7af3ea249103a" - "*": "" - size: # actuall size is not provided, use some default values to make clients happy - selector: div.category - case: - ":contains(\"Films\"):contains(\"1080p\")": "5GB" - ":contains(\"Films\"):contains(\"720p\")": "4GB" - ":contains(\"Films\"):contains(\"HDTV\")": "3GB" - ":contains(\"Films\"):contains(\"Bluray Remux\")": "20GB" - ":contains(\"Films\"):contains(\"Bluray 3D\")": "20GB" - ":contains(\"Films\"):contains(\"Bluray Remux 4K\")": "40GB" - ":contains(\"Films\"):contains(\"Bluray 4K\")": "40GB" - ":contains(\"Films\"):contains(\"Bluray\")": "20GB" - ":contains(\"Films\"):contains(\"WEB-DL\")": "5GB" - ":contains(\"Séries\"):contains(\"1080p\")": "3GB" - ":contains(\"Séries\"):contains(\"720p\")": "2GB" - ":contains(\"Séries\"):contains(\"HDTV\")": "1GB" - ":contains(\"Séries\"):contains(\"Bluray Remux\")": "20GB" - ":contains(\"Séries\"):contains(\"Bluray 3D\")": "20GB" - ":contains(\"Séries\"):contains(\"Bluray\")": "20GB" - ":contains(\"Animations\"):contains(\"1080p\")": "3GB" - ":contains(\"Animations\"):contains(\"720p\")": "2GB" - ":contains(\"Animations\"):contains(\"HDTV\")": "1GB" - ":contains(\"Animations\"):contains(\"Bluray Remux\")": "20GB" - ":contains(\"Animations\"):contains(\"Bluray 3D\")": "20GB" - ":contains(\"Animations\"):contains(\"Bluray\")": "20GB" - ":contains(\"Divers\"):contains(\"Logiciels\")": "0" - ":contains(\"Divers\"):contains(\"Clips\")": "1GB" - ":contains(\"Divers\"):contains(\"Pistes audios\")": "1GB" - ":contains(\"Divers\"):contains(\"Documentaires\")": "1GB" - ":contains(\"Divers\"):contains(\"Bluray\")": "20GB" - ":contains(\"Divers\"):contains(\"1080p\")": "5GB" - ":contains(\"Divers\"):contains(\"720p\")": "4GB" - ":contains(\"Films\"):contains(\"2160p\")": "20GB" - ":contains(\"Animations\"):contains(\"2160p\")": "20GB" - ":contains(\"Divers\"):contains(\"2160p\")": "20GB" - ":contains(\"Séries\"):contains(\"2160p\")": "20GB" - "*": "" - date: - text: now - download: - selector: div.download-item > a - attribute: href - seeders: - selector: div.seeders - filters: - - name: re_replace - args: ["^$", "999"] - leechers: - selector: div.leechers - filters: - - name: re_replace - args: ["^$", "999"] - grabs: - selector: div.completed - downloadvolumefactor: - case: - div.fl-label: 0 - "*": 1 - uploadvolumefactor: - text: 1 -# engine tbd diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/wintersakura.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/wintersakura.yml deleted file mode 100644 index 7a2400bf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/wintersakura.yml +++ /dev/null @@ -1,214 +0,0 @@ ---- -id: wintersakura -name: WinterSakura -description: "WinterSakura is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://wintersakura.net/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 409, cat: Other, desc: "Misc其他", default: true} - - {id: 407, cat: TV/Sport, desc: "Sports体育", default: true} - - {id: 406, cat: Audio/Video, desc: "Music Videos音乐MV", default: true} - - {id: 408, cat: Audio, desc: "HQ Audio无损音乐", default: true} - - {id: 418, cat: TV, desc: "TV Shows综艺(分集)", default: true} - - {id: 403, cat: TV, desc: "TV Shows综艺(Packs合集)", default: true} - - {id: 402, cat: TV, desc: "TV Series剧集(分集)", default: true} - - {id: 414, cat: TV, desc: "TV Series剧集(Packs合集)", default: true} - - {id: 413, cat: TV/Anime, desc: "Animation series动漫剧集(分集)", default: true} - - {id: 423, cat: TV/Anime, desc: "Animation series动漫剧集(Packs合集)", default: true} - - {id: 422, cat: TV/Anime, desc: "Animation flims动漫电影", default: true} - - {id: 410, cat: TV/Documentary, desc: "Documentaries纪录片", default: true} - - {id: 401, cat: Movies, desc: "Movies电影", default: true} - - {id: 427, cat: PC, desc: "Software软件/程序/代码", default: false} - - {id: 426, cat: Books/Technical, desc: "Papers期刊/论文", default: false} - - {id: 428, cat: Books/EBook, desc: "Books图书", default: false} - - {id: 425, cat: Other, desc: "Database数据/数据库", default: false} - - {id: 424, cat: Other, desc: "Course课程", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: 2facode - type: text - label: 2FA code - - name: info_2fa - type: info - label: "About 2FA code" - default: "Only fill in the 2FA code box if you have enabled 2FA on the WinterSakura Web Site. Otherwise just leave it empty." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - captcha: - type: image - selector: img[alt="CAPTCHA"] - input: imagestring - inputs: - secret: "" - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - two_step_code: "{{ .Config.2facode }}" - logout: "" - securelogin: "" - ssl: yes - trackerssl: yes - error: - - selector: td.embedded:has(h2:contains("失败")) - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [409, 407, 406, 408, 418, 403, 402, 414, 413, 423, 422, 410, 401] - - path: special.php - categories: [427, 426, 428, 425, 424] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 0.4 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.8.7 2023-09-04 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/world-of-tomorrow.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/world-of-tomorrow.yml deleted file mode 100644 index 9d8e9456..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/world-of-tomorrow.yml +++ /dev/null @@ -1,204 +0,0 @@ ---- -id: world-of-tomorrow -name: World-of-Tomorrow -description: "World-of-Tomorrow is a GERMAN Private tracker" -language: de-DE -type: private -encoding: UTF-8 -links: - - https://w-o-t.pro/ -legacylinks: - - https://world-of-tomorrow.eu/ - - https://wotreworked.xyz/ - -caps: - categorymappings: - # Apps - - {id: 1107, cat: PC/Mac, desc: "MAC"} - - {id: 1018, cat: PC, desc: "Sonstige"} - - {id: 1028, cat: PC/0day, desc: "Windows"} - # Audio - - {id: 1036, cat: Audio/Audiobook, desc: "Hörbücher"} - - {id: 1072, cat: Audio, desc: "Musik-Packs"} - - {id: 1093, cat: Audio/Video, desc: "Musik-Videos"} - - {id: 1070, cat: Audio/Lossless, desc: "Musik/Flac"} - - {id: 1014, cat: Audio/MP3, desc: "Musik/MP3"} - - {id: 1071, cat: Audio, desc: "Soundtracks"} - - {id: 1110, cat: Audio, desc: "WOTT"} - # Filme - - {id: 1049, cat: Movies/HD, desc: "1080p"} - - {id: 1090, cat: Movies/3D, desc: "3D"} - - {id: 1048, cat: Movies/HD, desc: "720p"} - - {id: 1087, cat: TV/Anime, desc: "Anime"} - - {id: 1047, cat: Movies/BluRay, desc: "BluRay"} - - {id: 1011, cat: Movies/DVD, desc: "DVD"} - - {id: 1060, cat: Movies/HD, desc: "HDTV"} - - {id: 1091, cat: Movies, desc: "Packs"} - - {id: 1062, cat: Movies/SD, desc: "SD"} - - {id: 1089, cat: Movies/UHD, desc: "UHD"} - - {id: 1089, cat: Movies/UHD, desc: "2160p"} - - {id: 1108, cat: Movies, desc: "WOTT"} - # Serien - - {id: 1079, cat: TV/HD, desc: "1080p"} - - {id: 1095, cat: TV/UHD, desc: "2160p"} - - {id: 1078, cat: TV/HD, desc: "720p"} - - {id: 1105, cat: TV/Anime, desc: "Anime"} - - {id: 1100, cat: TV/HD, desc: "Packs HD"} - - {id: 1016, cat: TV/SD, desc: "Packs-SD"} - - {id: 1077, cat: TV/SD, desc: "SD"} - - {id: 1109, cat: TV, desc: "WOTT"} - # Sonstiges - - {id: 1064, cat: TV/Documentary, desc: "Dokus"} - - {id: 1019, cat: Books/EBook, desc: "Ebooks"} - - {id: 1111, cat: Books/EBook, desc: "English Section"} - - {id: 1101, cat: TV, desc: "Kids"} - - {id: 1096, cat: Other, desc: "Sonstiges"} - - {id: 1092, cat: Console, desc: "Wimmelbild"} - - {id: 1088, cat: XXX, desc: "XXX"} - - {id: 1106, cat: XXX/ImageSet, desc: "XXX Bilder"} - - {id: 1102, cat: XXX/Other, desc: "XXX HENTAI"} - # Spiele - - {id: 1005, cat: Console, desc: "Konsolen"} - - {id: 1004, cat: PC/Games, desc: "Windows"} - # Sport - - {id: 1075, cat: TV/Sport, desc: "Formel 1"} - - {id: 1074, cat: TV/Sport, desc: "Fussball"} - - {id: 1073, cat: TV/Sport, desc: "Sport"} - - {id: 1023, cat: TV/Sport, desc: "Wrestling"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: pin - type: text - label: Pin - - name: freeleech - type: checkbox - label: Search FreeLeech - default: false - - name: onlyupload - type: checkbox - label: Search OnlyUpload - default: false - - name: info_free - type: info - label: About Freeleech and OnlyUpload at World-of-Tomorrow - default: "
  • FreeLeech are torrents where neither the download or upload is counted.
  • OnlyUpload are torrents where download is not counted but upload is. Good for building your Ratio up.
" - - name: sortby - type: select - label: Sort requested from site - default: added - options: - added: created - seeders: seeders - size: size - name: title - - name: typeby - type: select - label: Order requested from site - default: DESC - options: - DESC: desc - ASC: asc - - name: info_activity - type: info - label: Account Inactivity - default: "All accounts up to platinum users that are inactive for more than 8 weeks will be deleted!" - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - pin: "{{ .Config.pin }}" - remember: 1 - error: - - selector: div#loginStatus - test: - path: index.php - selector: a[href="logout.php"], title:contains("APRILSCHERZ") - -search: - paths: - # POST https://w-o-t.pro/search_query.php - # name=&categories[]=1107&maxAge=added&sortBy=added&sortByType=DESC&myTorrents=no&highlight=no&onlyupload=no&freeleech=no&offset=0&limit=10 - - path: search_query.php - method: post - - inputs: - name: "{{ .Keywords }}" - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - searchMethod: wildcard - maxAge: added - sortBy: "{{ .Config.sortby }}" - sortByType: "{{ .Config.typeby }}" - myTorrents: no - highlight: no - onlyupload: "{{ if .Config.onlyupload }}yes{{ else }}no{{ end }}" - freeleech: "{{ if .Config.freeleech }}yes{{ else }}no{{ end }}" - offset: 0 - limit: 100 - - rows: - selector: div.sr-card - - fields: - categorydesc: - selector: div.sr-badge-cat - remove: span.sr-badge-lang - title: - selector: div.sr-title-row a[href^="torrent.php"] - details: - selector: div.sr-title-row a[href^="torrent.php"] - attribute: href - download: - selector: a[href^="download.php?torrent="] - attribute: href - poster: - selector: div.sr-cover img - attribute: src - imdbid: - selector: a.sr-link-badge[href*="imdb.com"] - attribute: href - size: - selector: span.sr-detail-size - grabs: - selector: span.sr-detail-dl - seeders: - selector: span.sr-detail-seed - leechers: - selector: span.sr-detail-leech - date: - selector: span.sr-detail-time i - attribute: title - filters: - - name: append - args: " +01:00" - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - downloadvolumefactor: - case: - span.sr-status-onlyupload: 0 # only upload is counted - "*": 1 - uploadvolumefactor: - case: - span.sr-status-freeleech: 0 # nothing is counted - "*": 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 -# WoT Reworked v9.0.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/world-torrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/world-torrent.yml deleted file mode 100644 index 31f81250..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/world-torrent.yml +++ /dev/null @@ -1,166 +0,0 @@ ---- -id: world-torrent -replaces: - - zetorrents -name: World-torrent -description: "World-torrent is a FRENCH Public site for MOVIES / TV / GENERAL" -language: fr-FR -type: public -encoding: UTF-8 -# to fetch current domain use https://www.rantop.org/ -links: - - https://www.mania-torrent.org/ - - https://www.cine-torrent.org/ - - https://www.magnet-torrent.org/ - - https://www.mega-torrent.cc/ - - https://www.zone-torrent.com/ - - https://www.warez-torrent.org/ -legacylinks: - # latest domains list - - https://www.rantop.org/ - - https://www.site-torrent.cc/ - # domain lists no longer working - - https://www.protege-torrent.com/ - - https://www.torrent.ws/ - - https://www.protege-liens.com/ - - https://www.protege-liens.net/ - - https://www.torrent.onl/ - - https://www.site-torrent.com/ - - https://www.rantop.my/ - - https://www.torrent-site.com/ - # actual legacylinks - - https://ww1-zetorrents.me/ - - https://www.zetorrents.rent/ - - https://ww1-zetorrents.town/ - - https://ww1-cine-torrent.com/ - - https://ww2-cine-torrent.com/ - - https://ww3-cine-torrent.com/ - - https://www-magnet-torrent.com/ - - https://ww2-magnet-torrent.com/ - - https://www.magnet-torrent.com/ - - https://ww2-mania-torrent.com/ - - https://ww3-mania-torrent.com/ - - https://www.mania-torrent.com/ - - https://www-mega-torrent.com/ - - https://ww2-mega-torrent.com/ - - https://ww3-mega-torrent.com/ - - https://ww1-warez-torrent.com/ - - https://www-warez-torrent.com/ - - https://ww2-warez-torrent.com/ - - https://www.world-torrent1.com/ - - https://www-world-torrent.com/ - - https://ww1-world-torrent.com/ - - https://ww2-zone-torrent.com/ - - https://ww3-zone-torrent.com/ - - https://ww4-zone-torrent.com/ - -caps: - categorymappings: - - {id: Films, cat: Movies, desc: "Movies"} - - {id: Animes, cat: TV/Anime, desc: "Anime"} - - {id: Documentaires, cat: TV/Documentary, desc: "Documentaires"} - - {id: Séries, cat: TV, desc: "TV"} - - {id: Musiques, cat: Audio, desc: "Music"} - - {id: Ebooks, cat: Books, desc: "Books"} - - {id: Livres, cat: Books, desc: "Livres"} - - {id: Logiciels, cat: PC, desc: "Software"} - - {id: Applications, cat: PC, desc: "Applications"} - - {id: Jeux-PC, cat: PC/Games, desc: "PC Games"} - - {id: Jeux-Consoles, cat: Console/XBox 360, desc: "Console Games"} - - {id: Porno, cat: XXX, desc: "Porno"} - - {id: Spectacles, cat: TV/Other, desc: "Spectacles"} - - {id: Sports, cat: TV/Sport, desc: "Sports"} - - {id: Formations, cat: Other, desc: "Formations"} - - {id: Vidéos, cat: TV, desc: "Vidéos"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -download: - selectors: - - selector: a[href^="magnet:?"] - attribute: href - -search: - paths: - - path: "{{ if .Keywords }}recherche/{{ .Keywords }}{{ else }}derniers{{ end }}" - keywordsfilters: - # if searching for season packs switch S01 to saison 1 #9712 - - name: re_replace - args: ["(?i)(S0)(\\d{1,2})$", "saison $2"] - - name: re_replace - args: ["(?i)(S)(\\d{1,3})$", "saison $2"] - - rows: - selector: table.table > tbody > tr:has(a[href^="/detail/"]) - - fields: - category: - selector: i - attribute: class - title_phase1: - selector: a[href^="/detail/"] - filters: - - name: replace - args: ["WEBRIP", "WEBDL"] - - name: re_replace - args: ["(?i)\\b(FRENCH|MULTI|TRUEFRENCH|VOSTFR|SUBFRENCH)\\b(.+?)(\\b((19|20)\\d{2})\\b)$", "$3 $1$2"] - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: a[href^="/detail/"] - attribute: href - download: - selector: a[href^="/detail/"] - attribute: href - date: - text: now - size: - selector: td:nth-child(2) - seeders: - selector: td:nth-child(3) - leechers: - selector: td:nth-child(4) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xingwan.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xingwan.yml deleted file mode 100644 index a6d8721e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xingwan.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: xingwan -name: Xingwan (星湾) -description: "Xingwan (星湾) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://xingwan.cc/ - -caps: - categorymappings: - - {id: 401, cat: Movies, desc: "Movies(🎬 电影)"} - - {id: 402, cat: TV, desc: "TV Series(📺 电视剧)"} - - {id: 410, cat: TV, desc: "ShortDrama(📱 短剧)"} - - {id: 405, cat: TV/Anime, desc: "Animations(🌸 动漫)"} - - {id: 403, cat: TV, desc: "TV Shows(🎤 综艺)"} - - {id: 404, cat: TV/Documentary, desc: "Documentaries(🌍 纪录片)"} - - {id: 416, cat: Books/Comics, desc: "Comics(📖 漫画)"} - - {id: 406, cat: Audio/Video, desc: "Music Videos(🎵 音乐视频)"} - - {id: 408, cat: Audio, desc: "HQ Audio(🎧 无损音乐)"} - - {id: 407, cat: TV/Sport, desc: "Sports(⚽ 体育)"} - - {id: 411, cat: Console, desc: "Games(🎮 游戏)"} - - {id: 412, cat: PC, desc: "Software(💻 软件)"} - - {id: 413, cat: Books, desc: "Ebooks(📚 电子书)"} - - {id: 409, cat: Other, desc: "Misc(📦 其他杂项)"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href$="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xingyung.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xingyung.yml deleted file mode 100644 index 9be15d84..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xingyung.yml +++ /dev/null @@ -1,186 +0,0 @@ ---- -id: xingyung -name: Xingyung (星陨阁) -description: "Xingyung (星陨阁) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://pt.xingyungept.org/ - -caps: - categorymappings: - - {id: 409, cat: Other, desc: "其他/Misc"} - - {id: 408, cat: Audio, desc: "音频/HQ Audio"} - - {id: 406, cat: Audio/Video, desc: "MV/Music Videos"} - - {id: 407, cat: TV/Sport, desc: "体育/Sports"} - - {id: 410, cat: TV, desc: "短剧/Playlet"} - - {id: 404, cat: TV/Documentary, desc: "纪录片/Documentary"} - - {id: 405, cat: TV/Anime, desc: "动漫/Animations"} - - {id: 403, cat: TV, desc: "综艺/TV Shows"} - - {id: 402, cat: TV, desc: "电视剧/TVSeries"} - - {id: 401, cat: Movies, desc: "电影/Movies"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - text: 2.0 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xloli.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xloli.yml deleted file mode 100644 index 6fc9f575..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xloli.yml +++ /dev/null @@ -1,200 +0,0 @@ ---- -id: xloli -replaces: - - ilolicon -name: xloli -description: "xloli (ilolicon PT) is a CHINESE Private Torrent Tracker for LOLICON / MOVIES / 3X" -language: zh-CN -type: private -encoding: UTF-8 -requestDelay: 2 -links: - - https://mua.xloli.cc/ -legacylinks: - - https://share.ilolicon.com/ - -caps: - # dont forget to update the path categories in the search block - categorymappings: - - {id: 401, cat: Movies, desc: "电影(Movie)", default: true} - - {id: 402, cat: TV, desc: "电视剧(TV Series)", default: true} - - {id: 430, cat: TV, desc: "综艺(TV Show)", default: true} - - {id: 405, cat: TV/Anime, desc: "动画(Animation)", default: true} - - {id: 408, cat: Audio, desc: "音乐(Music)", default: true} - - {id: 410, cat: Audio/Video, desc: "舞台演出(Stage Performance)", default: true} - - {id: 404, cat: TV/Documentary, desc: "纪录片(Documentary)", default: true} - - {id: 412, cat: Console, desc: "游戏(Game)", default: true} - - {id: 413, cat: PC, desc: "软件(Software)", default: true} - - {id: 411, cat: Other, desc: "漫画/CG杂图/动漫杂志(Photo)", default: true} - - {id: 420, cat: XXX, desc: "AV(有码/Censored)", default: false} - - {id: 419, cat: XXX, desc: "AV(无码/Uncensored)", default: false} - - {id: 423, cat: XXX/Other, desc: "里番(Hanime)", default: false} - - {id: 425, cat: XXX/Other, desc: "H-2D同人动画(H 2D doujin anime)", default: false} - - {id: 426, cat: XXX/Other, desc: "H-3D同人动画(H 3D doujin anime)", default: false} - - {id: 424, cat: XXX/Other, desc: "H-漫画(H Manga)", default: false} - - {id: 427, cat: XXX/ImageSet, desc: "H-CG杂图(H-CG)", default: false} - - {id: 418, cat: XXX/ImageSet, desc: "写真(Picture)", default: false} - - {id: 429, cat: XXX/Other, desc: "音声(Audio)", default: false} - - {id: 428, cat: XXX/Other, desc: "H-游戏(H Game)", default: false} - - {id: 422, cat: XXX, desc: "H-综艺(H TV Show)", default: false} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile.
Choosing more may cause the web site to fail to deliver complete results. - - name: info_activity - type: info - label: Account Inactivity - default: "Cherish your user account. Inactive accounts would be deleted based on the following rules:
  1. Veteran User or above would never be deleted.
  2. Elite User or above would never be deleted if parked (at User CP).
  3. Parked accounts would be deleted if users have not logged in for more than 400 days in a row.
  4. Unparked accounts would be deleted if users have not logged in for more than 150 days in a row.
  5. Accounts with both uploaded and downloaded amount being 0 would be deleted if users have not logged in for more than 100 days in a row.
" - -login: - # using cookie method because login page has embedded Cloudflare CAPTCHA - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - categories: [401, 402, 430, 405, 408, 410, 404, 412, 413, 411] - - path: special.php - categories: [420, 419, 423, 425, 426, 424, 427, 418, 429, 428, 422] - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}{{ end }}{{ if .Query.DoubanID }}1{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }}{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - description: - selector: td.rowfollow:nth-child(2) td:nth-child(2) - remove: a, b, font, img -# NexusPHP v1.9.11 2025-11-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xtorrenty.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xtorrenty.yml deleted file mode 100644 index 370ac708..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xtorrenty.yml +++ /dev/null @@ -1,173 +0,0 @@ ---- -id: xtorrenty -name: xTorrenty -description: "xTorrenty is a POLISH Private Torrent Tracker for MOVIES / TV / GENERAL" -language: pl-PL -type: private -encoding: UTF-8 -links: - - https://xtorrenty.org/ - -caps: - categorymappings: - - {id: 15, cat: Movies/SD, desc: "Filmy XviD/DivX"} - - {id: 20, cat: Movies/Other, desc: "Filmy/Bajki"} - - {id: 21, cat: Movies/DVD, desc: "Filmy DVD"} - - {id: 22, cat: Movies/SD, desc: "Filmy RMVB"} - - {id: 23, cat: Movies/HD, desc: "Filmy x264"} - - {id: 24, cat: Movies/UHD, desc: "Filmy 4K UHD"} - - {id: 25, cat: Movies/HD, desc: "Filmy HD"} - - {id: 26, cat: Movies/3D, desc: "Filmy 3D"} - - {id: 27, cat: Movies/HD, desc: "Filmy x265"} - - {id: 28, cat: Movies/SD, desc: "Filmy TS/CAM"} - - {id: 84, cat: PC/0day, desc: "Programy"} - - {id: 85, cat: Console, desc: "Konsole"} - - {id: 16, cat: PC/Games, desc: "Gry"} - - {id: 86, cat: Audio, desc: "Muzyka"} - - {id: 88, cat: Audio/Video, desc: "Teledyski"} - - {id: 89, cat: TV, desc: "TV"} - - {id: 90, cat: XXX, desc: "Erotyka"} - - {id: 91, cat: Books/EBook, desc: "Książki"} - - {id: 92, cat: Books/Comics, desc: "Komiksy"} - - {id: 93, cat: PC/Mac, desc: "Linux/Mac"} - - {id: 94, cat: TV/Sport, desc: "Sport"} - - {id: 95, cat: TV/Anime, desc: "Anime"} - - {id: 96, cat: Other, desc: "Dla dzieci"} - - {id: 97, cat: PC/Mobile-Other, desc: "GSM/PDA"} - - {id: 344, cat: Other/Misc, desc: "Inne"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: POLISH - options: - POLISH: POLISH - MULTi POLISH: MULTi POLISH - - name: sort - type: select - label: Sort requested from site - default: date - options: - date: created - title: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - # using cookie method because login page has embedded Cloudflare turnstile - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: / - selector: a[href$="/index.php?action=logout"] - -download: - selectors: - - selector: a[href^="/engine/download.php?id="] - attribute: href - - selector: a[href^="magnet:?xt="] - attribute: href - -search: - # do=search&subaction=search&story=greyhound&titleonly=3&sortby=date&resorder=desc&showposts=1&catlist[]=23 - paths: - - path: index.php - inputs: - $raw: "{{ range .Categories }}catlist[]={{.}}&{{end}}" - do: search - subaction: search - showposts: 0 - # 0 in article 1 in comments 2 in static pages 3 in article titles - titleonly: "{{ if .Query.IMDBID }}0{{ else }}3{{ end }}" - story: "{{ if or .Query.IMDBID .Keywords }}{{ or .Query.IMDBID .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - sortby: "{{ .Config.sort }}" - resorder: "{{ .Config.type }}" - - rows: - selector: table > tbody > tr:has(td[class="colhead"]) - - fields: - category: - selector: td - case: - "td:contains(\"XviD/DivX\")": 15 - "td:contains(\"Filmy/Bajki\")": 20 - "td:contains(\"DVD\")": 21 - "td:contains(\"RMVB\")": 22 - "td:contains(\"x264\")": 23 - "td:contains(\"4K UHD\")": 24 - "td:contains(\"HD\")": 25 - "td:contains(\"3D\")": 26 - "td:contains(\"x265\")": 27 - "td:contains(\"TS/CAM\")": 28 - "td:contains(\"Programy\")": 84 - "td:contains(\"Konsole\")": 85 - "td:contains(\"Gry\")": 16 - "td:contains(\"Muzyka\")": 86 - "td:contains(\"Teledyski\")": 88 - "td:contains(\"TV\")": 89 - "td:contains(\"Erotyka\")": 90 - "td:contains(\"Książki\")": 91 - "td:contains(\"Komiksy\")": 92 - "td:contains(\"Linux/Mac\")": 93 - "td:contains(\"Sport\")": 94 - "td:contains(\"Anime\")": 95 - "td:contains(\"Dla dzieci\")": 96 - "td:contains(\"GSM/PDA\")": 97 - "*": 344 - title_phase1: - selector: a - title_multilang: - selector: a - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:POLISH|ENGLISH|\\bPL\\b)))\\b", "{{ .Config.multilanguage }}"] - - name: re_replace - args: ["(?i)\\b(pl)\\b", "POLISH"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - details: - selector: a - attribute: href - download: - selector: a - attribute: href - date: - text: now - seeders: - selector: td:nth-child(4) - leechers: - selector: td:nth-child(5) - size: - selector: td:nth-child(3) - filters: - - name: append - args: "B" - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# DataLife v22 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xtremebytes.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xtremebytes.yml deleted file mode 100644 index cf984955..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xtremebytes.yml +++ /dev/null @@ -1,154 +0,0 @@ ---- -id: xtremebytes -replaces: - - torrentsurf -name: Xtreme Bytes -description: "Xtreme Bytes (TorrentSurf) is a Private Torrent Tracker for MOVIES / TV / GENERAL" -language: en-US -type: private -encoding: UTF-8 -links: - - https://xtremebytes.net/ - -caps: - categorymappings: - - {id: 1, cat: TV/Anime, desc: "Anime"} - - {id: 13, cat: PC/Mac, desc: "Appz/Mac"} - - {id: 3, cat: PC/0day, desc: "Appz/PC"} - - {id: 35, cat: TV, desc: "Big Brother"} - - {id: 28, cat: Books/Comics, desc: "Comics"} - - {id: 4, cat: Books/EBook, desc: "E-Book"} - - {id: 39, cat: Console, desc: "Games/Console"} - - {id: 33, cat: PC/Games, desc: "Games/PC"} - - {id: 27, cat: Other, desc: "Misc"} - - {id: 37, cat: Movies/SD, desc: "Movies/Cam/TS"} - - {id: 14, cat: Movies/DVD, desc: "Movies/DVD-R"} - - {id: 18, cat: Movies/HD, desc: "Movies/HD"} - - {id: 19, cat: Movies/SD, desc: "Movies/SD"} - - {id: 42, cat: Movies/HD, desc: "Movies/x264"} - - {id: 41, cat: Movies/SD, desc: "Movies/Xvid"} - - {id: 29, cat: Audio, desc: "Music"} - - {id: 30, cat: TV, desc: "Packs"} - - {id: 24, cat: TV/HD, desc: "TV/HD"} - - {id: 25, cat: TV/SD, desc: "TV/SD"} - - {id: 44, cat: TV/HD, desc: "TV/x264"} - - {id: 43, cat: TV/SD, desc: "TV/Xvid"} - - {id: 36, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: seedbox - type: checkbox - label: "Use SeedBox Download Link" - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: / - error: - - selector: table.main:contains("Login failed!") - message: - selector: table tr td.text - test: - path: browse.php - selector: a[href="logout.php"] - -search: - paths: - # https://xtremebytes.net/browse.php?search=&cat=0&incldead=1&c18=1&c42=1 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech, 4 descr - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # site does not support imdbid searching or display imdb links in results. - - rows: - selector: table[border="0"][cellspacing="0"][cellpadding="5"] tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - title: - selector: a[href^="details.php?id="] - attribute: title - details: - selector: a[href^="details.php?id="] - attribute: href - download_1: - selector: a[href^="download.php"] - attribute: href - download_2: - selector: a[href^="download2.php"] - attribute: href - download: - text: "{{ if .Config.seedbox }}{{ .Result.download_2 }}{{ else }}{{ .Result.download_1 }}{{ end }}" - files: - selector: td:nth-child(3) - grabs: - selector: td:nth-child(7) - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/freeleech.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.8 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xwtclassics.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xwtclassics.yml deleted file mode 100644 index 9aa2e373..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xwtclassics.yml +++ /dev/null @@ -1,150 +0,0 @@ ---- -id: xwtclassics -name: XWT-Classics -description: "XWT-Classics is a Private Torrent Tracker for CLASSIC PROFESSIONAL WRESTLING" -language: en-US -type: private -encoding: UTF-8 -links: - - https://xwt-classics.net/ -legacylinks: - - http://xwt-classics.net/ - -caps: - categorymappings: - - {id: 1, cat: TV/Sport, desc: "DVD"} - - {id: 9, cat: TV/Sport, desc: "Florida"} - - {id: 7, cat: TV/Sport, desc: "International"} - - {id: 6, cat: TV/Sport, desc: "Japan"} - - {id: 8, cat: TV/Sport, desc: "Misc"} - - {id: 11, cat: TV/Sport, desc: "NWA"} - - {id: 4, cat: TV/Sport, desc: "UWF/Mid-South"} - - {id: 12, cat: TV/Sport, desc: "WCW"} - - {id: 5, cat: TV/Sport, desc: "World Class"} - - {id: 10, cat: TV/Sport, desc: "WWE 24/7"} - - {id: 13, cat: TV/Sport, desc: "WWE Network"} - - {id: 2, cat: TV/Sport, desc: "WWF"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: seedbox - type: checkbox - label: "Use SeedBox Download Link" - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "Accounts inactive for more than 42 days are automatically deleted." - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - error: - - selector: table.main:contains("Login failed!") - message: - selector: table tr td.text - test: - path: browse.php - selector: a[href="logout.php"] - -search: - paths: - # http://xwt-classics.net/browse.php?search=ricks&cat=0&incldead=1 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # 0 active, 1 incldead, 2 onlydead, 3 freleech - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[border="0"][cellspacing="0"][cellpadding="5"] tr:has(a[href^="download.php?id="]) - - fields: - category_optional: - selector: a[href^="browse.php?cat="] - attribute: href - optional: true - filters: - - name: querystring - args: cat - category: - text: "{{ if .Result.category_optional }}{{ .Result.category_optional }}{{ else }}8{{ end }}" - title: - selector: a[href^="details.php?id="] - attribute: title - download_1: - selector: a[href^="download.php"] - attribute: href - download_2: - selector: a[href^="download2.php"] - attribute: href - download: - text: "{{ if .Config.seedbox }}{{ .Result.download_2 }}{{ else }}{{ .Result.download_1 }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - files: - selector: td:nth-child(3) - grabs: - selector: td:nth-child(7) - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/freeleech.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xwtorrents.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xwtorrents.yml deleted file mode 100644 index 738647bf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xwtorrents.yml +++ /dev/null @@ -1,205 +0,0 @@ ---- -id: xwtorrents -name: XWtorrents -description: "XtremeWrestlingTorrents (XWT) is a Private Torrent Tracker for PROFESSIONAL WRESTLING / MMA" -language: en-US -type: private -encoding: windows-1252 -links: - - https://xtremewrestlingtorrents.net/ -legacylinks: - - http://xtremewrestlingtorrents.net/ - -caps: - categorymappings: - - {id: 82, cat: TV/Sport, desc: "AEW Collision"} - - {id: 83, cat: TV/Sport, desc: "AEW Collision HD"} - - {id: 74, cat: TV/Sport, desc: "AEW Dynamite"} - - {id: 75, cat: TV/Sport, desc: "AEW Dynamite HD"} - - {id: 78, cat: TV/Sport, desc: "AEW PPV's"} - - {id: 79, cat: TV/Sport, desc: "AEW PPV's HD"} - - {id: 76, cat: TV/Sport, desc: "AEW Rampage"} - - {id: 77, cat: TV/Sport, desc: "AEW Rampage HD"} - - {id: 47, cat: TV/Sport, desc: "Boxing"} - - {id: 14, cat: Movies, desc: "Documentary"} - - {id: 20, cat: TV/Sport, desc: "DVD"} - - {id: 88, cat: Books, desc: "eBooks"} - - {id: 2, cat: TV/Sport, desc: "ECW Original"} - - {id: 26, cat: TV/Sport, desc: "ECW Weekly"} - - {id: 21, cat: TV/Sport, desc: "Int Indy's"} - - {id: 69, cat: TV/Sport, desc: "Lucha Libre"} - - {id: 70, cat: TV/Sport, desc: "Lucha Libre HD"} - - {id: 23, cat: TV/Sport, desc: "Misc"} - - {id: 24, cat: TV/Sport, desc: "MMA"} - - {id: 59, cat: TV/Sport, desc: "MMA HD"} - - {id: 86, cat: TV/Sport, desc: "Netflix"} - - {id: 87, cat: TV/Sport, desc: "Netflix HD"} - - {id: 30, cat: TV/Sport, desc: "Packs"} - - {id: 68, cat: TV/Sport, desc: "Podcasts"} - - {id: 29, cat: TV/Sport, desc: "Puro"} - - {id: 18, cat: TV/Sport, desc: "ROH"} - - {id: 52, cat: TV/Sport, desc: "ROH HD"} - - {id: 45, cat: TV/Sport, desc: "Shoot Interviews"} - - {id: 4, cat: TV/Sport, desc: "Single Matches"} - - {id: 7, cat: TV/Sport, desc: "TNA Impact"} - - {id: 49, cat: TV/Sport, desc: "TNA Impact HD"} - - {id: 6, cat: TV/Sport, desc: "TNA PPV's"} - - {id: 55, cat: TV/Sport, desc: "TNA PPV's HD"} - - {id: 66, cat: TV/Sport, desc: "Total Divas"} - - {id: 67, cat: TV/Sport, desc: "Total Divas HD"} - - {id: 57, cat: TV/Sport, desc: "Tough Enough"} - - {id: 58, cat: TV/Sport, desc: "Tough Enough HD"} - - {id: 22, cat: TV/Sport, desc: "USA Indy's"} - - {id: 12, cat: TV/Sport, desc: "WCW"} - - {id: 71, cat: TV/Sport, desc: "Womens Wrestling"} - - {id: 15, cat: TV/Sport, desc: "Wrestling Films"} - - {id: 72, cat: TV/Sport, desc: "WWE 205 Live"} - - {id: 73, cat: TV/Sport, desc: "WWE 205 Live HD"} - - {id: 16, cat: TV/Sport, desc: "WWE Heat"} - - {id: 63, cat: TV/Sport, desc: "WWE Main Event"} - - {id: 64, cat: TV/Sport, desc: "WWE Network"} - - {id: 65, cat: TV/Sport, desc: "WWE Network HD"} - - {id: 46, cat: TV/Sport, desc: "WWE NXT"} - - {id: 50, cat: TV/Sport, desc: "WWE NXT HD"} - - {id: 9, cat: TV/Sport, desc: "WWE PPV's"} - - {id: 56, cat: TV/Sport, desc: "WWE PPV's HD"} - - {id: 10, cat: TV/Sport, desc: "WWE RAW"} - - {id: 51, cat: TV/Sport, desc: "WWE RAW HD"} - - {id: 11, cat: TV/Sport, desc: "WWE Smackdown"} - - {id: 53, cat: TV/Sport, desc: "WWE Smackdown HD"} - - {id: 43, cat: TV/Sport, desc: "WWE Superstars"} - - {id: 54, cat: TV/Sport, desc: "WWE Superstars HD"} - - {id: 89, cat: TV/Sport, desc: "WWE Vault"} - - {id: 19, cat: TV/Sport, desc: "WWE Velocity"} - - {id: 31, cat: TV/Sport, desc: "WWE Vintage"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: seedbox - type: checkbox - label: "Use SeedBox Download Link" - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: takelogin.php - method: post - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - returnto: / - error: - - selector: table.main:contains("Login failed!") - message: - selector: table tr td.text - test: - path: browse.php - selector: a[href="logout.php"] - -search: - paths: - # http://xtremewrestlingtorrents.net/browse.php?search=halftime+heat&c46=1&c51=1&c31=1&c11=1&incldead=1 - - path: browse.php - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - # 0 active, 1 incldead, 2 onlydead, 3 freeleech, 4 descr - incldead: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - search: "{{ .Keywords }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - - rows: - selector: table[border="0"][cellspacing="0"][cellpadding="5"] tr:has(a[href^="download.php?id="]) - - fields: - category: - selector: a[href^="browse.php?cat="] - attribute: href - filters: - - name: querystring - args: cat - download_1: - selector: a[href^="download.php"] - attribute: href - title_default: - # title can be missing so we use the &name= from the DL as a standby - text: "{{ .Result.download_1 }}" - filters: - - name: querystring - args: name - - name: replace - args: [".torrent", ""] - title: - selector: a[href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - download_2: - selector: a[href^="download2.php"] - attribute: href - download: - text: "{{ if .Config.seedbox }}{{ .Result.download_2 }}{{ else }}{{ .Result.download_1 }}{{ end }}" - details: - selector: a[href^="details.php?id="] - attribute: href - files: - selector: td:nth-child(3) - grabs: - selector: td:nth-child(7) - size: - selector: td:nth-child(6) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - date: - selector: td:nth-child(5) - filters: - - name: append - args: " -07:00" # PDT - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - downloadvolumefactor: - case: - img[src="pic/freeleech.png"]: 0 - "*": 1 - uploadvolumefactor: - text: 1 - minimumratio: - text: 0.7 - minimumseedtime: - # 1 day (as seconds = 24 x 60 x 60) - text: 86400 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xxxclub.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xxxclub.yml deleted file mode 100644 index fe16a8a1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xxxclub.yml +++ /dev/null @@ -1,82 +0,0 @@ ---- -id: xxxclub -name: XXXClub -description: "XXXClub is a Public torrent site for 3X" -language: en-US -type: public -encoding: UTF-8 -requestDelay: 3 -links: - - https://xxxclub.to/ - - https://xxxclub.cc/ - - https://xxxclub.me/ - -caps: - categorymappings: - - {id: 0, cat: XXX/SD, desc: "XXX 480p/SD"} - - {id: 1, cat: XXX/x264, desc: "XXX 720p/HD"} - - {id: 2, cat: XXX/x264, desc: "XXX 1080p/FullHD"} - - {id: 4, cat: XXX/UHD, desc: "XXX 2160p/UHD/4K"} - - {id: 3, cat: XXX/WEB-DL, desc: "XXX Movies/DVD/WEB"} - - {id: 5, cat: XXX/ImageSet, desc: "XXX IMAGESET"} - - {id: 6, cat: XXX/Other, desc: "XXX VR/VirtualReality"} - - {id: 7, cat: XXX/Pack, desc: "XXX Pack/MegaPack"} - - modes: - search: [q] - -settings: - - name: info_flaresolverr - type: info_flaresolverr - -download: - selectors: - - selector: a[href^="/torrents/download/"], a[href^="magnet:?xt="] - attribute: href - -search: - paths: - # https://xxxclub.to/torrents/browse/all/ - # https://xxxclub.to/torrents/search/all/metartx - - path: "torrents/{{ if .Keywords }}search/all/{{ .Keywords }}{{ else }}browse/all/{{ end }}" - - headers: - User-Agent: ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203"] - - rows: - selector: div.browsetableinside > ul > li:not(:first-child) - - fields: - category: - selector: a.catah - attribute: href - filters: - - name: regexp - args: (\d) - title: - selector: a[href^="/torrents/details/"] - details: - selector: a[href^="/torrents/details/"] - attribute: href - download: - selector: a[href^="/torrents/details/"] - attribute: href - poster: - selector: img - attribute: src - date: - selector: span.adde - filters: - - name: dateparse - args: "dd MMM yyyy HH:mm:ss" - size: - selector: span.siz - seeders: - selector: span.see - leechers: - selector: span.lee - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xxxtor.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xxxtor.yml deleted file mode 100644 index 467b7d3d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/xxxtor.yml +++ /dev/null @@ -1,90 +0,0 @@ ---- -id: xxxtor -name: xxxtor -description: "xxxtor is a RUSSIAN Public Torrent Tracker for 3X" -language: ru-RU -type: public -encoding: UTF-8 -links: - - https://xxxtor.com/ -legacylinks: - - https://xxxtor.org/ # redirects to .com - - https://xxxtor.info/ # redirects to .com - -caps: - categorymappings: - - {id: XXX, cat: XXX, desc: "XXX"} - - modes: - search: [q] - -settings: [] - -search: - # https://xxxtor.com/b.php?search=penny - paths: - - path: b.php - inputs: - search: "{{ if .Keywords }}{{ .Keywords }}{{ else }}{{ .Today.Year }}{{ end }}" - - rows: - selector: table > tbody > tr.gai - filters: - - name: andmatch - - fields: - category: - text: XXX - title: - selector: a[href^="/torrent/"] - details: - selector: a[href^="/torrent/"] - attribute: href - download: - selector: a[href^="/download/"] - attribute: href - magnet: - selector: a[href^="magnet:?xt="] - attribute: href - date: - selector: td:first-of-type - filters: - - name: replace - args: ["Янв", "Jan"] - - name: replace - args: ["Фев", "Feb"] - - name: replace - args: ["Мар", "Mar"] - - name: replace - args: ["Апр", "Apr"] - - name: replace - args: ["Май", "May"] - - name: replace - args: ["Июн", "Jun"] - - name: replace - args: ["Июл", "Jul"] - - name: replace - args: ["Авг", "Aug"] - - name: replace - args: ["Сен", "Sep"] - - name: replace - args: ["Окт", "Oct"] - - name: replace - args: ["Ноя", "Nov"] - - name: replace - args: ["Дек", "Dec"] - - name: append - args: " +03:00" # MSK - - name: dateparse - args: "dd MMM yy zzz" - size: - selector: td:nth-of-type(3) - seeders: - selector: td:last-of-type span:first-of-type - leechers: - selector: td:last-of-type span:last-of-type - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/yts.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/yts.yml deleted file mode 100644 index 1527df97..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/yts.yml +++ /dev/null @@ -1,136 +0,0 @@ ---- -id: yts -name: YTS -description: "YTS is a Public torrent site specialising in HD movies of small size" -type: public -language: en-US -encoding: UTF-8 -requestDelay: 2.5 # 2.5 requests per second (2 causes problems) -links: - # if the primary domain changes then don't forget to update the details, download and poster replace args - - https://yts.bz/ - # official domain list are at https://yifystatus.com/ and official proxies list are at https://ytsproxies.com/ - - https://yts.unblockninja.com/ - - https://yts.ninjaproxy1.com/ - - https://yts.proxyninja.org/ - - https://yts.proxyninja.net/ - - https://yts.torrentbay.st/ - - https://yts.torrentsbay.org/ -legacylinks: - - https://yts.am/ # redirects to .bz - - https://yts.ag/ # redirects to .bz - - https://yts.gg/ # redirects to .bz - - https://yts.mx/ - - https://yts.mrunblock.bond/ - - https://yts.nocensor.cloud/ - - https://yts.unblockit.download/ - - https://yts.lt/ # redirects to .bz - -caps: - categorymappings: - # note: the API does not support searching with categories, so these are dummy ones for torznab compatibility - # we map these newznab cats with the returned quality value in the releases routine. - - {id: 45, cat: Movies/HD, desc: "Movies/x264/720p"} - - {id: 44, cat: Movies/HD, desc: "Movies/x264/1080p"} - - {id: 46, cat: Movies/UHD, desc: "Movies/x264/2160p"} - - {id: 47, cat: Movies/3D, desc: "Movies/x264/3D"} - - modes: - search: [q] - movie-search: [q, imdbid] - -settings: [] - -search: - paths: - - path: api/v2/list_movies.json - response: - type: json - - inputs: - query_term: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # without this the API sometimes returns nothing - limit: 50 - sort_by: date_added - order_by: desc - keywordsfilters: - # ignore ' (e.g. search for america's Next Top Model) - - name: re_replace - args: ["[^\\w]+", " "] - - rows: - selector: data.movies - attribute: torrents - multiple: true - # bug at YTS can return movie_count > 0 and no movie torrents #12598 - missingAttributeEqualsNoResults: true - count: - selector: data.movie_count - - fields: - _quality: - selector: quality - category: - selector: quality - case: - "720p": 45 - "1080p": 44 - "2160p": 46 - "3D": 47 - "*": 45 - _audio: - selector: audio_channels - _depth: - selector: bit_depth - _type: - selector: type - _codec: - selector: video_codec - year: - selector: ..year - title_default: - selector: ..title - filters: - - name: append - args: " ({{ .Result.year }})" - title: - selector: ..title_long - optional: true - default: "{{ .Result.title_default }}" - filters: - - name: replace - args: [":", ""] - - name: append - args: " {{ .Result._quality }} {{ if eq .Result._type \"web\" }}WEBRip{{ else }}BRRip{{ end }} {{ if eq .Result._audio \"5.1\" }}5.1 {{ else }}{{ end }}{{ if eq .Result._depth \"10\" }}10Bit {{ else }}{{ end }}{{ .Result._codec }} -YTS" - details: - selector: ..url - filters: - - name: re_replace - args: ["^https?:\\/\\/yts\\.(mx|lt|bz)\\/", "{{ .Config.sitelink }}"] # fix for 12494 - download: - selector: url - filters: - - name: re_replace - args: ["^https?:\\/\\/yts\\.(mx|lt|bz)\\/", "{{ .Config.sitelink }}"] # fix for 12494 - infohash: - selector: hash - poster: - selector: ..large_cover_image - filters: - - name: re_replace - args: ["^https?:\\/\\/yts\\.(mx|lt|bz)\\/", "{{ .Config.sitelink }}"] # fix for 12494 - imdbid: - selector: ..imdb_code - date: - selector: date_uploaded_unix - size: - selector: size_bytes - seeders: - selector: seeds - leechers: - selector: peers - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# json api v2 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/yuscene-api.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/yuscene-api.yml deleted file mode 100644 index d59b3d81..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/yuscene-api.yml +++ /dev/null @@ -1,201 +0,0 @@ ---- -id: yuscene-api -name: YUSCENE (API) -description: "YUSCENE is a Private Tracker for MOVIES / TV" -language: en-US -type: private -encoding: UTF-8 -links: - - https://yu-scene.net/ - -caps: - categorymappings: - - {id: 1, cat: Movies, desc: "Movies"} - - {id: 2, cat: TV, desc: "TV"} - - {id: 7, cat: Console, desc: "Game"} - - {id: 8, cat: Audio, desc: "Music"} - - {id: 9, cat: PC, desc: "Application"} - - {id: 10, cat: Audio/Video, desc: "Music Video"} - - {id: 11, cat: TV/Sport, desc: "Sport"} - - {id: 12, cat: Books, desc: "eBook"} - - {id: 13, cat: Audio/Audiobook, desc: "Audiobook"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, tvdbid, tmdbid] - movie-search: [q, imdbid, tmdbid] - music-search: [q] - book-search: [q] - -settings: - - name: apikey - type: text - label: APIKey - - name: info_key - type: info - label: About your API key - default: "Find or Generate a new API Token by accessing your YUSCENE account My Settings page and clicking on the API Key tab." - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: single_file_release_use_filename - type: checkbox - label: Use filename as title for single file releases - default: true - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_activity - type: info - label: Account Inactivity - default: "You must log in once every 30 days or seed at least 1 torrent to not get disabled." - - name: info_flaresolverr - type: info_flaresolverr - -login: - path: /api/torrents - method: get - error: - - selector: a[href*="/login"] - message: - text: "The API key was not accepted by {{ .Config.sitelink }}." - - selector: :root:contains("Account is Banned") - -search: - paths: - # https://hdinnovations.github.io/UNIT3D/torrent_api.html - # https://github.com/HDInnovations/UNIT3D/blob/master/app/Http/Controllers/API/TorrentController.php#L657 - - path: api/torrents/filter - response: - type: json - - headers: - Authorization: ["Bearer {{ .Config.apikey }}"] - - inputs: - # if we have an id based search, add Season and Episode as query in name for UNIT3D < v6. Else pass S/E Params for UNIT3D >= v6 - $raw: "{{ range .Categories }}&categories[]={{.}}{{end}}" - name: "{{ .Keywords }}" - seasonNumber: "{{ .Query.Season }}" - episodeNumber: "{{ .Query.Ep }}" - imdbId: "{{ .Query.IMDBIDShort }}" - tmdbId: "{{ .Query.TMDBID }}" - tvdbId: "{{ .Query.TVDBID }}" - "free[]": "{{ if .Config.freeleech }}100{{ else }}{{ end }}" - sortField: "{{ .Config.sort }}" - sortDirection: "{{ .Config.type }}" - perPage: 100 - - keywordsfilters: - - name: re_replace - args: ["\\.", " "] - - rows: - selector: data - attribute: attributes - - fields: - category: - selector: category_id - title_optional: - selector: name - title_filename: - selector: "files[0].name" - optional: true - files: - selector: num_file - title: - text: "{{ if and (.Config.single_file_release_use_filename) (eq .Result.files \"1\") (.Result.title_filename) }}{{ .Result.title_filename }}{{ else }}{{ .Result.title_optional }}{{ end }}" - details: - selector: details_link - download: - selector: download_link - poster: - selector: meta.poster - filters: - - name: replace - args: ["https://via.placeholder.com/90x135", ""] - imdbid: - selector: imdb_id - tmdbid: - selector: tmdb_id - tvdbid: - selector: tvdb_id - genre: - selector: meta.genres - filters: - - name: re_replace - args: ["(?i)(Science Fiction)", "Science_Fiction"] - - name: re_replace - args: ["(?i)(TV Movie)", "TV_Movie"] - - name: replace - args: [" & ", "_&_"] - _internal: - selector: internal - case: - False: "{{ .False }}" - True: "{{ .True }}" - description: - text: "{{ if .Result._internal }}Internal{{ else }}{{ end }}{{ if and .Result._internal .Result.genre }} | {{ else }}{{ end }}{{ .Result.genre }}" - seeders: - selector: seeders - leechers: - selector: leechers - grabs: - selector: times_completed - date: - # "created_at": "2021-10-18T00:34:50.000000Z" is returned by Newtonsoft.Json.Linq as 18/10/2021 00:34:50 - selector: created_at - filters: - - name: append - args: " +00:00" # GMT - - name: dateparse - args: "MM/dd/yyyy HH:mm:ss zzz" - size: - selector: size - _featured: - selector: featured - case: - False: "{{ .False }}" - True: "{{ .True }}" - downloadvolumefactor_freeleech: - # api returns 0%, 25%, 50%, 75%, 100% - selector: freeleech - case: - 0%: 1 # not free - 25%: 0.75 - 50%: 0.5 - 75%: 0.25 - 100%: 0 # freeleech - "*": 0 # catch errors - downloadvolumefactor: - text: "{{ if .Result._featured }}0{{ else }}{{ .Result.downloadvolumefactor_freeleech }}{{ end }}" - uploadvolumefactor_double_upload: - # api returns False, True - selector: double_upload - case: - False: 1 # normal - True: 2 # double - uploadvolumefactor: - text: "{{ if .Result._featured }}2{{ else }}{{ .Result.uploadvolumefactor_double_upload }}{{ end }}" -# global MR is 0.7 but torrents must be seeded for 5 days regardless of ratio -# minimumratio: -# text: 0.7 - minimumseedtime: - # 5 days (as seconds = 5 x 24 x 60 x 60) - text: 432000 -# json UNIT3D 9.1.7 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zamundarip.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zamundarip.yml deleted file mode 100644 index fa8fe461..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zamundarip.yml +++ /dev/null @@ -1,100 +0,0 @@ ---- -id: zamundarip -name: Zamunda RIP -description: "Zamunda RIP is a BULGARIAN Public Torrent Archive for MOVIES / TV / GENERAL" -language: bg-BG -type: public -encoding: UTF-8 -links: - - https://zamunda.rip/ - -caps: - categorymappings: - # from https://zamunda.rip/api/torznab/api?t=caps - - {id: 1000, cat: Console, desc: "Console"} - - {id: 1050, cat: Console/XBox, desc: "Игри/Xbox"} - - {id: 2000, cat: Movies, desc: "Movies"} - - {id: 2040, cat: Movies/HD, desc: "Филми/HD"} - - {id: 2030, cat: Movies/SD, desc: "Филми/SD"} - - {id: 2010, cat: Movies, desc: "Филми/Русия"} - - {id: 2020, cat: Movies/Other, desc: "Филми/Документални"} - - {id: 2050, cat: Movies/BluRay, desc: "Blu-ray"} - - {id: 2060, cat: Movies/3D, desc: "Филми/3D"} - - {id: 3000, cat: Audio, desc: "Audio"} - - {id: 3010, cat: Audio, desc: "Музика"} - - {id: 3020, cat: Audio/Video, desc: "Музика/DVD-R"} - - {id: 3040, cat: Audio, desc: "Музика/DTS"} - - {id: 4000, cat: PC, desc: "PC"} - - {id: 4050, cat: PC/Games, desc: "Игри/PC ISO"} - - {id: 4030, cat: PC/Mac, desc: "Игри/Mac"} - - {id: 4020, cat: PC/ISO, desc: "Програми/PC ISO"} - - {id: 4010, cat: PC, desc: "Програми/други"} - - {id: 4040, cat: PC/Mobile-Other, desc: "Mobile/GSM"} - - {id: 4070, cat: PC/Mobile-Android, desc: "Андроид/Игри"} - - {id: 5000, cat: TV, desc: "TV"} - - {id: 5070, cat: TV/Anime, desc: "Анимации/Аниме"} - - {id: 5030, cat: TV, desc: "Сериали"} - - {id: 5040, cat: TV/HD, desc: "Сериали/HD"} - - {id: 5020, cat: TV, desc: "Сериали/Русия"} - - {id: 5060, cat: TV/Sport, desc: "Спорт/TV"} - - {id: 6000, cat: XXX, desc: "XXX"} - - {id: 6040, cat: XXX/x264, desc: "XXX"} - - {id: 7000, cat: Books, desc: "Other"} - - {id: 7030, cat: Books/Comics, desc: "Книги/Комикси"} - - {id: 8000, cat: Other, desc: "Misc"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - -settings: [] - -search: - paths: - # https://zamunda.rip/api/torznab/api?t=search&limit=100 - - path: api/torznab/api - response: - type: xml - - inputs: - t: search - q: "{{ .Keywords }}" - cat: "{{ join .Categories \",\" }}" - limit: 100 - - rows: - selector: rss > channel > item - - fields: - category: - selector: '[name="category"]' - attribute: value - title: - selector: title - details: - text: "{{ .Config.sitelink }}" - download: - selector: link - date: - # Sun, 08 Feb 2026 19:31:03 +0000 - selector: pubDate - filters: - - name: dateparse - args: "ddd, dd MMM yyyy HH:mm:ss zzz" - size: - selector: '[name="size"]' - attribute: value - seeders: - selector: '[name="seeders"]' - attribute: value - leechers: - selector: '[name="peers"]' - attribute: value - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# torznab xml diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zappateers.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zappateers.yml deleted file mode 100644 index 0b078f1b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zappateers.yml +++ /dev/null @@ -1,185 +0,0 @@ ---- -id: zappateers -name: Zappateers -description: "Zappateers is a Private Torrent Tracker for Frank Zappa MUSIC" -language: en-US -type: private -encoding: UTF-8 -links: - - https://zappateers.com/ - -caps: - categorymappings: - - {id: 1, cat: Audio, desc: "FZ Audio"} - - {id: 6, cat: Audio, desc: "Samples & Fills"} - - {id: 7, cat: Audio, desc: "FZ Outtakes/Unreleased"} - - {id: 8, cat: Audio, desc: "FZ Liberated Boots"} - - {id: 2, cat: Audio/Video, desc: "FZ Video"} - - {id: 9, cat: Audio/Video, desc: "FZ Related"} - - {id: 10, cat: Audio, desc: "FZ Interviews/Radio"} - - {id: 11, cat: Other, desc: "Misc"} - - {id: 12, cat: Audio, desc: "The Beefheart Corner"} - - {id: 13, cat: Audio/Video, desc: "The Mike Keneally Corner"} - - {id: 14, cat: Audio, desc: "The Shoebox"} - - {id: 16, cat: Audio, desc: "The Shoebox Archive"} - - {id: 15, cat: Other, desc: "Covers"} - - modes: - search: [q] - music-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: created_at - options: - created_at: created - seeders: seeders - size: size - name: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login - method: form - form: form[action$="/login"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - remember: on - selectorinputs: - _token: - selector: input[name="_token"] - attribute: value - error: - - selector: div#ERROR_COPY -# test: -# path: / -# selector: a[href$="/logout"] - -search: - # https://zappateers.com/torrents/filter?search=orange&description=&uploader=&view=list&freeleech=1&sorting=created_at&direction=desc&page=0&qty=25 - paths: - - path: torrents/filter - inputs: - $raw: "{{ range .Categories }}categories[]={{.}}&{{end}}" - search: "{{ .Keywords }}" - sorting: "{{ .Config.sort }}" - direction: "{{ .Config.type }}" - qty: 50 - page: 0 - view: list - freeleech: "{{ if .Config.freeleech }}1{{ else }}{{ end }}" - - rows: - selector: table > tbody > tr - - fields: - category: - selector: a[href*="/categories/"] - attribute: href - filters: - - name: regexp - args: "/categories/(\\d+)" - title: - selector: a.view-torrent - download: - selector: a[href*="/download/"] - attribute: href - details: - selector: a.view-torrent - attribute: href - poster: - selector: div.torrent-poster img - attribute: src - filters: - - name: prepend - args: "https://images.weserv.nl/?url=" # for display on dashboard - - name: append - args: "&w=180&h=270" # for display on dashboard - - name: replace - args: ["https://images.weserv.nl/?url=https://via.placeholder.com/600x900&w=180&h=270", ""] - size: - selector: td:nth-last-child(4) - seeders: - selector: td:nth-last-child(3) - leechers: - selector: td:nth-last-child(2) - grabs: - selector: td:nth-last-child(1) - date: - selector: time - filters: - # translations for Turkish|Estonian|Danish|Italian|Polish|Norwegian|Portuguese|Czech|Russian|Romanian|Spanish|French|German|Bulgarian|Dutch|Chinese|Japanese|Swedish - - name: re_replace - args: ["(?i)(önce|tagasi|geleden|fa|temu|siden|há|atrás|nazpět|назад|acum|în urmă|hace|il y a|vor|преди|前|sedan)", " ago"] - - name: re_replace - args: ["(?i)(saniye|sekundit|sekunder|secondi|sekund|segundos|sekundami|секунд|secunde|secondes|Sekunden|секунди|seconden|秒前)", "seconds"] - - name: re_replace - args: ["(?i)(minutit|minutter|minuti|minuty|minutos|минуты|минут|Minuten|минути|minuten|minuter)", "minutes"] - - name: re_replace - args: ["(?i)(dakika|minut|minuto|minuta|minutt|минута|Minute|minuut|分钟|分)", " minute"] - - name: re_replace - args: ["(?i)(tundi|timer|ore|godziny|horas|hodiny|hoden|часа|часов|ore|heures|Stunden|timmar)", "hours"] - - name: re_replace - args: ["(?i)(saat|tund|time|ora|godzina|hora|hodina|час|oră|heure|Stunde|uur|小时|時間|timme)", " hour"] - - name: re_replace - args: ["(?i)(päeva|dage|giorni|dni|dias|dny|дня|дней|zile|días|jours|Tagen|дни|dagen|dagar)", "days"] - - name: re_replace - args: ["(?i)(gün|päev|dag|giorno|dzień|dia|den|день|zi|día|jour|Tag|ден|天|日)", " day"] - - name: re_replace - args: ["(?i)(nädalat|uger|settimane|tygodnie|uker|semanas|týdny|недели|недель|săptămâni|semaines|Wochen|седмици|weken|veckor)", "weeks"] - - name: re_replace - args: ["(?i)(hafta|nädal|uge|settimana|tydzień|uke|semana|týden|неделю|săptămână|semaine|Woche|седмица|周|週間|vecka)", " week"] - - name: re_replace - args: ["(?i) (ay)", "month"] - - name: re_replace - args: ["(?i)(kuud|måneder|mesi|miesiące|meses|měsíce|месяца|месяцев|luni|meses|mois|Monaten|месеца|maanden|månader)", "months"] - - name: re_replace - args: ["(?i)(kuu|måned|mese|miesiąc|mês|měsíc|месяц|lună|mes|Monat|месец|maand|个月|ヶ月|månad)", " month"] - - name: re_replace - args: ["(?i)(aastat|anni|lata|anos|roky|года|ani|años|ans|Jahren|години)", " years"] - - name: re_replace - args: ["(?i)(yil|aasta|år|anno|rok|ano|год|año|Jahr|година|jaar|年)", " year"] - - name: re_replace - args: ["(?i) (an)", "year"] - - name: re_replace - args: ["(?i)(För |und)", ""] # Misc removals - - name: timeago - downloadvolumefactor: - case: - i[class*="fa-id-badge text-orange"]: 0 # 24 Hour FreeLeech From BON Store - i[class*="fa-star text-bold"]: 0 # Freeleech From Token - i[class*="fa-coins text-bold"]: 0 # Freeleech From Token - i[class*="fa-globe text-blue"]: 0 # Global Freeleech - i[class*="fa-star text-gold"]: 0 # Freeleech - i[class*="fa-certificate text-pink"]: 0 # Featured Torrent - "*": 1 - uploadvolumefactor: - case: - i[class*="fa-gem text-green"]: 2 # Single Torrent Double Upload - i[class*="fa-globe text-green"]: 2 # Global Double Upload - i[class*="fa-certificate text-pink"]: 2 # Featured Torrent - i[class*="fa-trophy text-purple"]: 2 # Special double upload - "*": 1 - minimumseedtime: - # 2 days (as seconds = 2 x 24 x 60 x 60) - text: 172800 -# UNIT3D 3.0.0 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zktorrent.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zktorrent.yml deleted file mode 100644 index f1a39500..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zktorrent.yml +++ /dev/null @@ -1,157 +0,0 @@ ---- -id: zktorrent -replaces: - - gktorrent -name: ZkTorrent -description: "ZkTorrent is a FRENCH Public site for MOVIES / TV / GENERAL" -language: fr-FR -type: public -encoding: UTF-8 -# to fetch current domain use https://www.rantop.org/ -links: - - https://www.zktorrent.org/ -legacylinks: - # latest domains list - - https://www.rantop.org/ - - https://www.site-torrent.cc/ - # domain lists no longer working - - https://www.protege-torrent.com/ - - https://www.torrent.ws/ - - https://www.protege-liens.com/ - - https://www.protege-liens.net/ - - https://www.torrent.onl/ - - https://gktorrent.news/ - - https://www.site-torrent.com/ - - https://www.rantop.my/ - - https://www.torrent-site.com/ - # actual legacylinks - - https://www.zktorrent4.com/ - - https://www.zktorrent5.com/ - - https://www.zktorrent.net/ - - https://www.zktorrent6.com/ - - https://www.zktorrent.town/ - - https://zktorrent-proxy.site/ - - https://ww1-zktorrent.me/ - - https://www.zktorrent.rent/ - - https://ww1-zktorrent.town/ - - https://www.zktorrent7.com/ - - https://www.zktorrent8.com/ - - https://ww1-zktorrent.com/ - - https://www-zktorrent.com/ - - https://ww2-zktorrent.com/ - - https://www.zktorrent.com/ - -caps: - categorymappings: - - {id: Films, cat: Movies, desc: "Movies"} - - {id: Vidéos, cat: Movies, desc: "Vidéos"} - - {id: Animes, cat: TV/Anime, desc: "Anime"} - - {id: Séries, cat: TV, desc: "TV Series"} - - {id: Spectacles, cat: TV, desc: "TV Shows"} - - {id: Documentaires, cat: TV/Documentary, desc: "Documentaries"} - - {id: Sports, cat: TV/Sport, desc: "Sport"} - - {id: Musiques, cat: Audio, desc: "Music"} - - {id: Ebooks, cat: Books, desc: "Books"} - - {id: Livres, cat: Books, desc: "Books"} - - {id: Logiciels, cat: PC, desc: "Software"} - - {id: Jeux-PC, cat: PC/Games, desc: "PC Games"} - - {id: Jeux-Consoles, cat: Console/XBox 360, desc: "Console Games"} - - {id: Porno, cat: XXX, desc: "XXX"} - - modes: - search: [q] - tv-search: [q, season, ep] - movie-search: [q] - music-search: [q] - book-search: [q] - allowrawsearch: true - -settings: - - name: info_flaresolverr - type: info_flaresolverr - - name: multilang - type: checkbox - label: Replace MULTi by another language in release name - default: false - - name: multilanguage - type: select - label: Replace MULTi by this language - default: FRENCH - options: - FRENCH: FRENCH - MULTi FRENCH: MULTi FRENCH - ENGLISH: ENGLISH - MULTi ENGLISH: MULTi ENGLISH - VOSTFR: VOSTFR - MULTi VOSTFR: MULTi VOSTFR - - name: vostfr - type: checkbox - label: Replace VOSTFR and SUBFRENCH with ENGLISH - default: false - -download: - selectors: - - selector: a[href^="magnet:?"] - attribute: href - - selector: script:contains("magnet:?") - filters: - - name: regexp - args: "\\s'(magnet:\\?.+?)';" - -search: - paths: - - path: "{{ if .Keywords }}recherche/{{ .Keywords }}{{ else }}home{{ end }}" - keywordsfilters: - # if searching for season packs swith S01 to saison 1 #9712 - - name: re_replace - args: ["(?i)(S0)(\\d{1,2})$", "saison $2"] - - name: re_replace - args: ["(?i)(S)(\\d{1,3})$", "saison $2"] - - rows: - selector: table.table-hover > tbody > tr:has(td a[href]) - - fields: - category: - selector: i - attribute: class - title_phase1: - selector: a - filters: - - name: re_replace - args: ["(?i)\\b(FRENCH|MULTI|TRUEFRENCH|VOSTFR|SUBFRENCH)\\b(.+?)(\\b((19|20)\\d{2})\\b)$", "$3 $1$2"] - title_vostfr: - text: "{{ .Result.title_phase1 }}" - filters: - - name: re_replace - args: ["(?i)\\b(vostfr|subfrench)\\b", "ENGLISH"] - title_phase2: - text: "{{ if .Config.vostfr }}{{ .Result.title_vostfr }}{{ else }}{{ .Result.title_phase1 }}{{ end }}" - title_multilang: - text: "{{ .Result.title_phase2 }}" - filters: - - name: re_replace - args: ["(?i)\\b(MULTI(?!.*(?:FRENCH|ENGLISH|VOSTFR)))\\b", "{{ .Config.multilanguage }}"] - title: - text: "{{ if .Config.multilang }}{{ .Result.title_multilang }}{{ else }}{{ .Result.title_phase2 }}{{ end }}" - details: - selector: a - attribute: href - filters: - - name: regexp - args: (/?detail/\d+) - download: - text: "{{ .Result.details }}" - size: - selector: td:nth-child(2) - date: - text: now - seeders: - selector: td:nth-child(3) - leechers: - selector: td:nth-child(4) - downloadvolumefactor: - text: 0 - uploadvolumefactor: - text: 1 -# engine n/a diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zmpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zmpt.yml deleted file mode 100644 index f4d0a879..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zmpt.yml +++ /dev/null @@ -1,196 +0,0 @@ ---- -id: zmpt -name: ZmPT (织梦) -description: "ZmPT (织梦) is a CHINESE Private Torrent Tracker for MOVIES / TV / GENERAL" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://zmpt.cc/ - -caps: - categorymappings: - - {id: 409, cat: Other, desc: "其他 / Misc"} - - {id: 422, cat: TV/Documentary, desc: "纪录片 / documentary"} - - {id: 417, cat: TV/Anime, desc: "动漫 / Anime"} - - {id: 427, cat: Audio/Video, desc: "短剧 / Short Play"} - - {id: 401, cat: Movies, desc: "电影 / Movies"} - - {id: 402, cat: TV, desc: "电视剧 / TV Series"} - - {id: 403, cat: TV, desc: "综艺 / TV Shows"} - - {id: 423, cat: Audio, desc: "音乐 / Music"} - - {id: 424, cat: Audio/Audiobook, desc: "有声书 / Audiobook"} - - {id: 425, cat: PC, desc: "软件 / Software"} - - {id: 426, cat: Console, desc: "游戏 / Game"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid, doubanid] - movie-search: [q, imdbid, doubanid] - music-search: [q] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Dou Sheng users and above will be retained forever
  2. Dou Huang and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 180 consecutive days
  4. Users with a non-parked account will be banned if they do not log in for 45 consecutive days
  5. Users with no traffic (ie, upload/download data are both 0) will be banned if they do not log in for 7 consecutive days.
" - -login: - # using cookie method because site regularly disables login page for certain events during the year - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ end }}{{ if or .Query.IMDBID .Query.DoubanID }} {{ else }}{{ .Keywords }}{{ end }}{{ if .Query.DoubanID }}{{ .Query.DoubanID }}{{ else }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl (not working) - search_area: "{{ if or .Query.IMDBID .Query.DoubanID }}1{{ else }}0{{ end }}" - # 0 AND, 1 OR, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href^="details.php?id="]) - - fields: - category: - selector: a[href^="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href^="details.php?id="] - title: - selector: a[title][href^="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href^="details.php?id="] - attribute: href - download: - selector: a[href^="download.php?id="] - attribute: href - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - doubanid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="movie.douban.com/subject/"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumratio: - case: - img[title="H&R"]: 1.0 - "*": 0.4 - minimumseedtime: - # H&R 3 days (as seconds = 3 x 24 x 60 x 60) - # normal 1 day (as seconds = 24 x 60 x 60) - case: - img[title="H&R"]: 259200 - "*": 86400 - description_verified: - case: - span[title="通过"], span[title="通過"], span[title="Allowed"]: "Verified:" - span[title="未审"], span[title="未審"], span[title="Not reviewed"]: "Unverified:" - span[title="拒绝"], span[title="拒絕"], span[title="Denied"]: "Banned:" - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span - filters: - - name: prepend - args: "{{ .Result.description_verified }} " -# NexusPHP v1.9.9 2025-10-02 diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zrpt.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zrpt.yml deleted file mode 100644 index 30d7008e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/zrpt.yml +++ /dev/null @@ -1,179 +0,0 @@ ---- -id: zrpt -name: ZRPT (自然) -description: "ZRPT (自然) is a CHINESE Private Torrent Tracker for Documentaries" -language: zh-CN -type: private -encoding: UTF-8 -links: - - https://zrpt.cc/ - -caps: - categorymappings: - - {id: 412, cat: TV/Documentary, desc: "Nature"} - - {id: 413, cat: TV/Documentary, desc: "NatureWeb"} - - {id: 401, cat: TV/Documentary, desc: "军事纪实"} - - {id: 402, cat: TV/Documentary, desc: "生物自然"} - - {id: 403, cat: TV/Documentary, desc: "餐饮美食"} - - {id: 404, cat: TV/Documentary, desc: "人文记录"} - - {id: 405, cat: TV/Documentary, desc: "宇宙星球"} - - {id: 406, cat: TV/Documentary, desc: "音乐视频"} - - {id: 407, cat: TV/Documentary, desc: "体育运动"} - - {id: 409, cat: TV/Documentary, desc: "其它"} - - {id: 415, cat: TV/Documentary, desc: "旅游风景"} - - {id: 416, cat: TV/Documentary, desc: "纪录片"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - -settings: - - name: cookie - type: text - label: Cookie - - name: info_cookie - type: info_cookie - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - - name: info_tpp - type: info - label: Results Per Page - default: For best results, change the Torrents per page: setting to 100 on your account profile. - - name: info_activity - type: info - label: Account Inactivity - default: "Account retention rules:
  1. Veteran User and above will be retained forever
  2. Elite User and above will not have their account deleted after parking (in the control panel)
  3. Users with a parked account will be deleted if they do not log in for 400 consecutive days
  4. Users with a non-parked account will be deleted if they do not log in for 150 consecutive days
  5. Users who have no traffic (i.e. upload/download data are both 0) will be deleted if they do not log in for 100 consecutive days.
" - -login: - # using cookie method because site does a JS call to API/Challenge via JQuery to load response parm required for takelogin.php - method: cookie - inputs: - cookie: "{{ .Config.cookie }}" - test: - path: index.php - selector: a[href="logout.php"] - -search: - paths: - - path: torrents.php - inputs: - $raw: "{{ range .Categories }}cat{{.}}=1&{{end}}" - search: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # 0 incldead, 1 active, 2 dead - incldead: 0 - # 0 all, 1 normal, 2 free, 3 2x, 4 2xfree, 5 50%, 6 2x50%, 7 30% - spstate: "{{ if .Config.freeleech }}2{{ else }}0{{ end }}" - # 0 title, 1 descr, 3 uploader, 4 imdburl - search_area: "{{ if .Query.IMDBID }}4{{ else }}0{{ end }}" - # 0 AND, 2 exact - search_mode: 0 - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - notnewword: 1 - - rows: - selector: table.torrents > tbody > tr:has(a[href*="download.php?id="]) - - fields: - category: - selector: a[href*="?cat="] - attribute: href - filters: - - name: querystring - args: cat - title_default: - selector: a[href*="details.php?id="] - title: - selector: a[title][href*="details.php?id="] - attribute: title - optional: true - default: "{{ .Result.title_default }}" - details: - selector: a[href*="details.php?id="] - attribute: href - download: - selector: a[href*="download.php?id="] - attribute: href - poster: - selector: img[data-src] - attribute: data-src - imdbid: - # site currently only has a badge and rating, the id is not present. just in case a future update. - selector: a[href*="imdb.com/title/tt"] - attribute: href - date_elapsed: - # time type: time elapsed (default) - selector: td.rowfollow:nth-child(4) > span[title] - attribute: title - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-dd HH:mm:ss zzz" - date_added: - # time added - selector: td.rowfollow:nth-child(4):not(:has(span)) - optional: true - filters: - - name: append - args: " +08:00" # CST - - name: dateparse - args: "yyyy-MM-ddHH:mm:ss zzz" - date: - text: "{{ if or .Result.date_elapsed .Result.date_added }}{{ or .Result.date_elapsed .Result.date_added }}{{ else }}now{{ end }}" - size: - selector: td.rowfollow:nth-child(5) - optional: true - default: 512MB - seeders: - selector: td.rowfollow:nth-child(6) - optional: true - default: 0 - leechers: - selector: td.rowfollow:nth-child(7) - optional: true - default: 0 - grabs: - selector: td.rowfollow:nth-child(8) - optional: true - default: 0 - downloadvolumefactor: - case: - img.pro_free: 0 - img.pro_free2up: 0 - img.pro_50pctdown: 0.5 - img.pro_50pctdown2up: 0.5 - img.pro_30pctdown: 0.3 - "*": 1 - uploadvolumefactor: - case: - img.pro_50pctdown2up: 2 - img.pro_free2up: 2 - img.pro_2up: 2 - "*": 1 - minimumseedtime: - # 1 days (as seconds = 1 x 24 x 60 x 60) - text: 86400 - description: - selector: td.rowfollow:nth-child(2) - remove: a, b, font, img, span -# NexusPHP v1.9.6 2025-06-25 (custom) diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ztracker.yml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ztracker.yml deleted file mode 100644 index 6a6579f1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/definitions/ztracker.yml +++ /dev/null @@ -1,195 +0,0 @@ ---- -id: ztracker -name: Ztracker -description: "Ztracker is a HUNGARIAN Semi-Private Torrent Tracker for 0DAY / GENERAL" -language: hu-HU -type: semi-private -encoding: ISO-8859-2 -links: - - https://ztracker.cc/ -legacylinks: - - http://ztracker.org/ - - http://ztracker.cc/ - -caps: - categorymappings: - - {id: 29, cat: Movies/SD, desc: "CAM/EN"} - - {id: 30, cat: Movies/SD, desc: "CAM/HUN"} - - {id: 49, cat: Books, desc: "EBOOK/EN"} - - {id: 3, cat: Books, desc: "EBOOK/HU"} - - {id: 37, cat: Movies/UHD, desc: "FILM/4K/EN"} - - {id: 32, cat: Movies/UHD, desc: "FILM/4K/HU"} - - {id: 36, cat: Movies/DVD, desc: "FILM/DVD/EN"} - - {id: 34, cat: Movies/DVD, desc: "FILM/DVD/HU"} - - {id: 35, cat: Movies/HD, desc: "FILM/HD/EN"} - - {id: 31, cat: Movies/HD, desc: "FILM/HD/HU"} - - {id: 8, cat: Movies/SD, desc: "FILM/SD/EN"} - - {id: 28, cat: Movies/SD, desc: "|-- Mese/Külf."} - - {id: 26, cat: TV/SD, desc: "|-- SOROZAT/SD/EN"} - - {id: 7, cat: Movies/SD, desc: "FILM/SD/HU"} - - {id: 27, cat: Movies/SD, desc: "|-- Mese/Hun"} - - {id: 25, cat: TV/SD, desc: "|-- SOROZAT/SD/HU"} - - {id: 45, cat: Console, desc: "JÁTÉK/KONZOL"} - - {id: 4, cat: PC/Games, desc: "JÁTÉK/PC/ISO"} - - {id: 18, cat: Other, desc: "Képek"} - - {id: 17, cat: XXX, desc: "|-- XXX/IMAGESET"} - - {id: 24, cat: PC/Mobile-Other, desc: "PROG/MOBIL"} - - {id: 1, cat: PC/0day, desc: "PROG/PC/ISO"} - - {id: 47, cat: TV/UHD, desc: "SOROZAT/4K/EN"} - - {id: 48, cat: TV/UHD, desc: "SOROZAT/4K/HU"} - - {id: 51, cat: TV/SD, desc: "SOROZAT/DVD/EN"} - - {id: 50, cat: TV/SD, desc: "SOROZAT/DVD/HU"} - - {id: 46, cat: TV/HD, desc: "SOROZAT/HD/EN"} - - {id: 44, cat: TV/HD, desc: "SOROZAT/HD/HU"} - - {id: 41, cat: XXX, desc: "XXX/4K"} - - {id: 40, cat: XXX, desc: "XXX/DVD"} - - {id: 16, cat: XXX, desc: "XXX/HD"} - - {id: 15, cat: XXX, desc: "XXX/SD"} - - {id: 12, cat: Audio/MP3, desc: "ZENE/MP3/EN"} - - {id: 11, cat: Audio/MP3, desc: "ZENE/MP3/HU"} - - modes: - search: [q] - tv-search: [q, season, ep, imdbid] - movie-search: [q, imdbid] - music-search: [q] - book-search: [q] - -settings: - - name: username - type: text - label: Username - - name: password - type: password - label: Password - - name: freeleech - type: checkbox - label: Search freeleech only - default: false - - name: sort - type: select - label: Sort requested from site - default: 4 - options: - 4: created - 7: seeders - 5: size - 1: title - - name: type - type: select - label: Order requested from site - default: desc - options: - desc: desc - asc: asc - -login: - path: login.php - method: form - form: form[action="takelogin.php"] - inputs: - username: "{{ .Config.username }}" - password: "{{ .Config.password }}" - logintype: yes - error: - - selector: div.error - - selector: table:has(img[src="/pic/ts_error/error.jpg"]) - message: - selector: table:has(img[src="/pic/ts_error/error.jpg"]) - remove: style - test: - path: index.php - selector: a[href*="/logout.php?logouthash="] - -search: - paths: - # http://ztracker.cc/browse_old.php?keywords=&search_type=t_both&cat=0&ts_type=1 - - path: browse_old.php - keywordsfilters: - - name: re_replace - args: ["[^a-zA-Z0-9]+", "%"] - inputs: - $raw: "{{ range .Categories }}c{{.}}=1&{{end}}" - keywords: "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}" - # t_name, t_description, t_both, t_uploader - search_type: "{{ if .Query.IMDBID }}t_description{{ else }}t_name{{ end }}" - # 0 active, 1 both, 2 inactive, 3 free, 19 bluray, 4 silver, 10 doubleup, 5 today, 6 yesterday, 7 week, 8 month, 9 last ten, external - ts_type: "{{ if .Config.freeleech }}3{{ else }}1{{ end }}" - sort: "{{ .Config.sort }}" - type: "{{ .Config.type }}" - # does not return imdb link in results - - rows: - selector: table[border="1"] > tbody > tr:has(a[href*="details.php?id="]) - - fields: - category: - selector: a[href^="/browse_old.php?cat="] - attribute: href - optional: true - default: 18 - filters: - - name: querystring - args: cat - title: - selector: a[href*="details.php?id="][onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: (.*?) - details: - selector: a[href*="details.php?id="][onmouseover] - attribute: href - download: - selector: a[href*="details.php?id="] - attribute: href - filters: - - name: replace - args: ["details.php", "download.php"] - poster: - selector: a[href*="details.php?id="][onmouseover] - attribute: onmouseover - filters: - - name: regexp - args: img src=\\'(.*?)\\' - imdbid: - selector: a[href*="imdb.com/title/tt"] - attribute: href - files: - selector: td:nth-child(5) - size: - selector: td:nth-child(11):has(b) - optional: true - default: 0 B - remove: b - seeders: - selector: td:nth-child(7) - leechers: - selector: td:nth-child(8) - downloadvolumefactor: - case: - img[src="./pic/freedownload.gif"]: 0 - img[src="./pic/silverdownload.gif"]: 0.5 - "*": 1 - uploadvolumefactor: - case: - img[src="./pic/x2.gif"]: 2 - "*": 1 - minimumratio: - text: 0.8 - minimumseedtime: - # 3 days (as seconds = 3 x 24 x 60 x 60) - text: 259200 - date: - selector: td:nth-child(2) - remove: a, img - filters: - - name: replace - args: ["\xA0", " "] - - name: replace - args: ["Ma", "Today"] - - name: replace - args: ["Tegnap", "Yesterday"] - - name: re_replace - args: ["12:(\\d\\d) PM", "00:$1 PM"] -# TS Special Edition diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/categories.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/categories.rs deleted file mode 100644 index 86315c33..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/categories.rs +++ /dev/null @@ -1,217 +0,0 @@ -//! Category mapping between Cardigann indexer categories and Newznab standard categories. - -use std::collections::HashMap; - -use crate::definition::{CapabilitiesBlock, CategoryMappingBlock}; - -/// A resolved category mapping: indexer category ID → Newznab category IDs. -#[derive(Debug, Clone, Default)] -pub struct CategoryMapper { - /// Indexer category string → list of Newznab category IDs. - mappings: HashMap>, - /// Reverse: Newznab category ID → list of indexer category strings. - reverse: HashMap>, -} - -impl CategoryMapper { - /// Build a category mapper from a Cardigann capabilities block. - pub fn from_caps(caps: &CapabilitiesBlock) -> Self { - let mut mapper = Self::default(); - - if let Some(ref mappings) = caps.categorymappings { - for mapping in mappings { - let indexer_id = match &mapping.id { - serde_yaml::Value::Number(n) => n.to_string(), - serde_yaml::Value::String(s) => s.clone(), - other => format!("{other:?}"), - }; - - let newznab_id = newznab_name_to_id(&mapping.cat); - - mapper - .mappings - .entry(indexer_id.clone()) - .or_default() - .push(newznab_id); - - mapper - .reverse - .entry(newznab_id) - .or_default() - .push(indexer_id); - } - } - - // Simple string-based categories (e.g., `categories: { "Movies": "Movies" }`) - if let Some(ref cats) = caps.categories { - for (key, cat_name) in cats { - let newznab_id = newznab_name_to_id(cat_name); - mapper - .mappings - .entry(key.clone()) - .or_default() - .push(newznab_id); - mapper - .reverse - .entry(newznab_id) - .or_default() - .push(key.clone()); - } - } - - mapper - } - - /// Map an indexer-specific category to Newznab category IDs. - pub fn to_newznab(&self, indexer_cat: &str) -> Vec { - self.mappings - .get(indexer_cat) - .cloned() - .unwrap_or_default() - } - - /// Map Newznab category IDs to indexer-specific category strings. - pub fn from_newznab(&self, newznab_ids: &[i32]) -> Vec { - let mut result = Vec::new(); - for id in newznab_ids { - if let Some(cats) = self.reverse.get(id) { - result.extend(cats.clone()); - } - } - result.sort(); - result.dedup(); - result - } -} - -/// Map a Newznab category name (e.g., "Movies/HD") to its numeric ID. -/// -/// Based on the Newznab standard category numbering scheme. -pub fn newznab_name_to_id(name: &str) -> i32 { - match name { - // Console - "Console" => 1000, - "Console/NDS" => 1010, - "Console/PSP" => 1020, - "Console/Wii" => 1030, - "Console/XBox" => 1040, - "Console/XBox 360" => 1050, - "Console/Wiiware" => 1060, - "Console/XBox 360 DLC" => 1070, - "Console/PS3" => 1080, - "Console/Other" => 1090, - "Console/3DS" => 1110, - "Console/PS Vita" => 1120, - "Console/WiiU" => 1130, - "Console/XBox One" => 1140, - "Console/PS4" => 1180, - - // Movies - "Movies" => 2000, - "Movies/Foreign" => 2010, - "Movies/Other" => 2020, - "Movies/SD" => 2030, - "Movies/HD" => 2040, - "Movies/UHD" => 2045, - "Movies/BluRay" => 2050, - "Movies/3D" => 2060, - "Movies/DVD" => 2070, - "Movies/WEB-DL" => 2080, - - // Audio - "Audio" => 3000, - "Audio/MP3" => 3010, - "Audio/Video" => 3020, - "Audio/Audiobook" => 3030, - "Audio/Lossless" => 3040, - "Audio/Other" => 3050, - "Audio/Foreign" => 3060, - - // PC - "PC" => 4000, - "PC/0day" => 4010, - "PC/ISO" => 4020, - "PC/Mac" => 4030, - "PC/Mobile-Other" => 4040, - "PC/Games" => 4050, - "PC/Mobile-iOS" => 4060, - "PC/Mobile-Android" => 4070, - - // TV - "TV" => 5000, - "TV/WEB-DL" => 5010, - "TV/Foreign" => 5020, - "TV/SD" => 5030, - "TV/HD" => 5040, - "TV/UHD" => 5045, - "TV/Other" => 5050, - "TV/Sport" => 5060, - "TV/Anime" => 5070, - "TV/Documentary" => 5080, - - // XXX - "XXX" => 6000, - "XXX/DVD" => 6010, - "XXX/WMV" => 6020, - "XXX/XviD" => 6030, - "XXX/x264" => 6040, - "XXX/UHD" => 6045, - "XXX/Pack" => 6050, - "XXX/ImageSet" => 6060, - "XXX/Other" => 6070, - "XXX/SD" => 6080, - "XXX/WEB-DL" => 6090, - - // Books - "Books" => 7000, - "Books/Mags" => 7010, - "Books/EBook" => 7020, - "Books/Comics" => 7030, - "Books/Technical" => 7040, - "Books/Other" => 7050, - "Books/Foreign" => 7060, - - // Other - "Other" => 8000, - "Other/Misc" => 8010, - "Other/Hashed" => 8020, - - _ => 8010, // Default to Other/Misc - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn basic_mapping() { - let caps = CapabilitiesBlock { - categorymappings: Some(vec![ - CategoryMappingBlock { - id: serde_yaml::Value::Number(45.into()), - cat: "Movies/HD".into(), - desc: Some("720p".into()), - default: None, - }, - CategoryMappingBlock { - id: serde_yaml::Value::Number(44.into()), - cat: "Movies/HD".into(), - desc: Some("1080p".into()), - default: None, - }, - ]), - categories: None, - modes: None, - allowrawsearch: None, - }; - - let mapper = CategoryMapper::from_caps(&caps); - assert_eq!(mapper.to_newznab("45"), vec![2040]); - assert_eq!(mapper.to_newznab("44"), vec![2040]); - - let indexer_cats = mapper.from_newznab(&[2040]); - assert!(indexer_cats.contains(&"44".to_owned())); - assert!(indexer_cats.contains(&"45".to_owned())); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/definition.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/definition.rs deleted file mode 100644 index 7488c1b9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/definition.rs +++ /dev/null @@ -1,505 +0,0 @@ -//! Serde structs for Cardigann YAML definitions. -//! -//! These mirror Prowlarr's `CardigannDefinition.cs` types and are deserialized -//! from the YAML files served at `https://indexers.prowlarr.com/master/{version}`. - -use indexmap::IndexMap; -use serde::Deserialize; - -/// Root definition loaded from a single YAML file. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CardigannDefinition { - pub id: String, - pub name: String, - #[serde(default)] - pub description: Option, - #[serde(rename = "type")] - pub privacy: Option, - #[serde(default)] - pub language: Option, - #[serde(default)] - pub encoding: Option, - #[serde(default, rename = "requestDelay")] - pub request_delay: Option, - #[serde(default)] - pub links: Vec, - #[serde(default)] - pub legacylinks: Option>, - #[serde(default)] - pub followredirect: Option, - #[serde(default, rename = "testlinktorrent")] - pub test_link_torrent: Option, - #[serde(default)] - pub certificates: Option>, - #[serde(default)] - pub settings: Option>, - pub caps: CapabilitiesBlock, - #[serde(default)] - pub login: Option, - #[serde(default)] - pub ratio: Option, - pub search: SearchBlock, - #[serde(default)] - pub download: Option, -} - -/// A user-configurable setting (API key, username, sort order, etc.). -#[derive(Debug, Clone, Deserialize)] -pub struct SettingsField { - pub name: String, - #[serde(rename = "type")] - pub field_type: String, - #[serde(default)] - pub label: Option, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub defaults: Option>, - #[serde(default)] - pub options: Option>, -} - -/// Indexer capability declarations — categories and search modes. -#[derive(Debug, Clone, Deserialize)] -pub struct CapabilitiesBlock { - #[serde(default)] - pub categories: Option>, - #[serde(default)] - pub categorymappings: Option>, - #[serde(default)] - pub modes: Option>>, - #[serde(default)] - pub allowrawsearch: Option, -} - -/// Maps an indexer-specific category ID to a Newznab standard category. -#[derive(Debug, Clone, Deserialize)] -pub struct CategoryMappingBlock { - pub id: serde_yaml::Value, - pub cat: String, - #[serde(default)] - pub desc: Option, - #[serde(default)] - pub default: Option, -} - -// --------------------------------------------------------------------------- -// Login -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize)] -pub struct LoginBlock { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub submitpath: Option, - #[serde(default)] - pub cookies: Option>, - #[serde(default)] - pub method: Option, - #[serde(default)] - pub form: Option, - #[serde(default)] - pub selectors: Option, - #[serde(default)] - pub inputs: Option>, - #[serde(default)] - pub selectorinputs: Option>, - #[serde(default)] - pub getselectorinputs: Option>, - #[serde(default)] - pub error: Option>, - #[serde(default)] - pub test: Option, - #[serde(default)] - pub captcha: Option, - #[serde(default)] - pub headers: Option>>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct CaptchaBlock { - #[serde(rename = "type")] - pub captcha_type: Option, - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub input: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PageTestBlock { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub selector: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ErrorBlock { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub message: Option, -} - -// --------------------------------------------------------------------------- -// Search -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize)] -pub struct SearchBlock { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub paths: Option>, - #[serde(default)] - pub headers: Option>>, - #[serde(default)] - pub keywordsfilters: Option>, - #[serde(default, rename = "allowEmptyInputs")] - pub allow_empty_inputs: Option, - #[serde(default)] - pub inputs: Option>, - #[serde(default)] - pub error: Option>, - #[serde(default)] - pub preprocessingfilters: Option>, - pub rows: RowsBlock, - pub fields: IndexMap, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct SearchPathBlock { - pub path: String, - #[serde(default)] - pub method: Option, - #[serde(default)] - pub inputs: Option>, - #[serde(default)] - pub queryseparator: Option, - #[serde(default)] - pub categories: Option>, - #[serde(default)] - pub inheritinputs: Option, - #[serde(default)] - pub followredirect: Option, - #[serde(default)] - pub response: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct ResponseBlock { - #[serde(rename = "type")] - pub response_type: Option, - #[serde(default, rename = "noResultsMessage")] - pub no_results_message: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RowsBlock { - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub attribute: Option, - #[serde(default)] - pub optional: Option, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub text: Option, - #[serde(default)] - pub remove: Option, - #[serde(default)] - pub filters: Option>, - #[serde(default, rename = "case")] - pub case_map: Option>, - #[serde(default)] - pub after: Option, - #[serde(default)] - pub dateheaders: Option, - #[serde(default)] - pub count: Option, - #[serde(default)] - pub multiple: Option, - #[serde(default, rename = "missingAttributeEqualsNoResults")] - pub missing_attribute_equals_no_results: Option, -} - -// --------------------------------------------------------------------------- -// Field / Selector / Filter primitives -// --------------------------------------------------------------------------- - -/// A single field extraction rule (used in `search.fields`). -/// This is a superset of `SelectorBlock` — it can have `selector`, `text`, -/// `attribute`, `filters`, `optional`, `default`, and `case`. -#[derive(Debug, Clone, Deserialize)] -pub struct FieldBlock { - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub optional: Option, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub text: Option, - #[serde(default)] - pub attribute: Option, - #[serde(default)] - pub remove: Option, - #[serde(default)] - pub filters: Option>, - #[serde(default, rename = "case")] - pub case_map: Option>, -} - -/// A CSS/JSON selector for extracting a single value from a document. -#[derive(Debug, Clone, Deserialize)] -pub struct SelectorBlock { - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub optional: Option, - #[serde(default)] - pub default: Option, - #[serde(default)] - pub text: Option, - #[serde(default)] - pub attribute: Option, - #[serde(default)] - pub remove: Option, - #[serde(default)] - pub filters: Option>, - #[serde(default, rename = "case")] - pub case_map: Option>, -} - -/// A transform applied to an extracted value. -#[derive(Debug, Clone, Deserialize)] -pub struct FilterBlock { - pub name: String, - #[serde(default, deserialize_with = "deserialize_filter_args")] - pub args: FilterArgs, -} - -/// Filter arguments — can be absent, a single string, or a list of strings. -#[derive(Debug, Clone)] -pub enum FilterArgs { - None, - Single(String), - List(Vec), -} - -impl Default for FilterArgs { - fn default() -> Self { - FilterArgs::None - } -} - -fn deserialize_filter_args<'de, D>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::de; - - struct FilterArgsVisitor; - - impl<'de> de::Visitor<'de> for FilterArgsVisitor { - type Value = FilterArgs; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("a string, list of strings, or null") - } - - fn visit_unit(self) -> Result { - Ok(FilterArgs::None) - } - - fn visit_none(self) -> Result { - Ok(FilterArgs::None) - } - - fn visit_str(self, v: &str) -> Result { - Ok(FilterArgs::Single(v.to_owned())) - } - - fn visit_string(self, v: String) -> Result { - Ok(FilterArgs::Single(v)) - } - - fn visit_bool(self, v: bool) -> Result { - Ok(FilterArgs::Single(v.to_string())) - } - - fn visit_i64(self, v: i64) -> Result { - Ok(FilterArgs::Single(v.to_string())) - } - - fn visit_u64(self, v: u64) -> Result { - Ok(FilterArgs::Single(v.to_string())) - } - - fn visit_f64(self, v: f64) -> Result { - Ok(FilterArgs::Single(v.to_string())) - } - - fn visit_seq>(self, mut seq: A) -> Result { - let mut items = Vec::new(); - while let Some(item) = seq.next_element::()? { - match item { - serde_yaml::Value::String(s) => items.push(s), - serde_yaml::Value::Number(n) => items.push(n.to_string()), - serde_yaml::Value::Bool(b) => items.push(b.to_string()), - other => items.push(format!("{other:?}")), - } - } - Ok(FilterArgs::List(items)) - } - } - - deserializer.deserialize_any(FilterArgsVisitor) -} - -// --------------------------------------------------------------------------- -// Download -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize)] -pub struct DownloadBlock { - #[serde(default)] - pub selectors: Option>, - #[serde(default)] - pub method: Option, - #[serde(default)] - pub before: Option, - #[serde(default)] - pub infohash: Option, - #[serde(default)] - pub headers: Option>>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct SelectorField { - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub attribute: Option, - #[serde(default)] - pub usebeforeresponse: Option, - #[serde(default)] - pub filters: Option>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct InfohashBlock { - #[serde(default)] - pub hash: Option, - #[serde(default)] - pub title: Option, - #[serde(default)] - pub usebeforeresponse: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct BeforeBlock { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub method: Option, - #[serde(default)] - pub inputs: Option>, - #[serde(default)] - pub queryseparator: Option, - #[serde(default)] - pub pathselector: Option, -} - -// --------------------------------------------------------------------------- -// Ratio -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Deserialize)] -pub struct RatioBlock { - #[serde(default)] - pub path: Option, - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub optional: Option, - #[serde(default)] - pub attribute: Option, - #[serde(default)] - pub filters: Option>, -} - -// --------------------------------------------------------------------------- -// Meta definition (from the index endpoint) -// --------------------------------------------------------------------------- - -/// Lightweight metadata returned by the definition listing endpoint. -#[derive(Debug, Clone, Deserialize)] -pub struct CardigannMetaDefinition { - pub id: String, - pub file: String, - pub name: String, - #[serde(default)] - pub description: Option, - #[serde(rename = "type")] - pub privacy: Option, - #[serde(default)] - pub language: Option, - #[serde(default)] - pub protocol: Option, - #[serde(default)] - pub implementation: Option, - #[serde(default)] - pub links: Option>, - #[serde(default)] - pub legacylinks: Option>, - #[serde(default)] - pub settings: Option>, - #[serde(default)] - pub caps: Option, - #[serde(default)] - pub sha: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_yts_definition() { - let yaml = include_str!("../../../test-fixtures/cardigann/yts.yml"); - let def: CardigannDefinition = serde_yaml::from_str(yaml).expect("failed to parse yts.yml"); - assert_eq!(def.id, "yts"); - assert_eq!(def.name, "YTS"); - assert!(def.search.fields.contains_key("title")); - assert!(def.search.fields.contains_key("seeders")); - } - - #[test] - fn parse_thepiratebay_definition() { - let yaml = include_str!("../../../test-fixtures/cardigann/thepiratebay.yml"); - let def: CardigannDefinition = - serde_yaml::from_str(yaml).expect("failed to parse thepiratebay.yml"); - assert_eq!(def.id, "thepiratebay"); - assert!(def.settings.is_some()); - // TPB has 3 settings - assert!(def.settings.as_ref().unwrap().len() >= 3); - } - - #[test] - fn parse_1337x_definition() { - let yaml = include_str!("../../../test-fixtures/cardigann/1337x.yml"); - let def: CardigannDefinition = - serde_yaml::from_str(yaml).expect("failed to parse 1337x.yml"); - assert_eq!(def.id, "1337x"); - // 1337x has a download block with selectors - assert!(def.download.is_some()); - assert!(def.download.as_ref().unwrap().selectors.is_some()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/filters.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/filters.rs deleted file mode 100644 index 42b4d100..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/filters.rs +++ /dev/null @@ -1,457 +0,0 @@ -//! Filter implementations for Cardigann field value transforms. -//! -//! Each filter takes an input string and optional arguments and returns -//! the transformed string. These are applied sequentially to extracted values. - -use anyhow::{Context, Result, bail}; -use chrono::{Datelike, NaiveDate, NaiveDateTime, Utc}; -use regex::Regex; - -use crate::definition::FilterArgs; - -/// Apply a named filter to the input value. -pub fn apply_filter(name: &str, input: &str, args: &FilterArgs) -> Result { - match name { - "querystring" => filter_querystring(input, args), - "regexp" => filter_regexp(input, args), - "re_replace" => filter_re_replace(input, args), - "split" => filter_split(input, args), - "replace" => filter_replace(input, args), - "trim" => filter_trim(input, args), - "prepend" => filter_prepend(input, args), - "append" => filter_append(input, args), - "tolower" => Ok(input.to_lowercase()), - "toupper" => Ok(input.to_uppercase()), - "urldecode" => filter_urldecode(input), - "urlencode" => filter_urlencode(input), - "htmldecode" => filter_htmldecode(input), - "htmlencode" => filter_htmlencode(input), - "timeago" | "reltime" => filter_timeago(input), - "fuzzytime" => filter_fuzzytime(input), - "dateparse" | "timeparse" => filter_dateparse(input, args), - "validfilename" => filter_validfilename(input), - "diacritics" => filter_diacritics(input), - "jsonjoinarray" => filter_jsonjoinarray(input, args), - "validate" => filter_validate(input, args), - "hexdump" | "strdump" => Ok(input.to_owned()), - other => { - tracing::warn!(filter = other, "unknown Cardigann filter, passing through"); - Ok(input.to_owned()) - } - } -} - -/// Extract a query string parameter value from a URL. -fn filter_querystring(input: &str, args: &FilterArgs) -> Result { - let key = match args { - FilterArgs::Single(s) => s.as_str(), - FilterArgs::List(v) if !v.is_empty() => v[0].as_str(), - _ => bail!("querystring filter requires a key argument"), - }; - let url = url::Url::parse(input) - .or_else(|_| url::Url::parse(&format!("http://dummy?{input}"))) - .context("querystring: failed to parse URL")?; - for (k, v) in url.query_pairs() { - if k == key { - return Ok(v.into_owned()); - } - } - Ok(String::new()) -} - -/// Extract the first capture group from a regex match. -fn filter_regexp(input: &str, args: &FilterArgs) -> Result { - let pattern = match args { - FilterArgs::Single(s) => s.as_str(), - FilterArgs::List(v) if !v.is_empty() => v[0].as_str(), - _ => bail!("regexp filter requires a pattern argument"), - }; - let re = Regex::new(pattern).context("regexp: invalid pattern")?; - if let Some(caps) = re.captures(input) { - if let Some(m) = caps.get(1) { - return Ok(m.as_str().to_owned()); - } - if let Some(m) = caps.get(0) { - return Ok(m.as_str().to_owned()); - } - } - Ok(String::new()) -} - -/// Regex search-and-replace. -fn filter_re_replace(input: &str, args: &FilterArgs) -> Result { - let (pattern, replacement) = match args { - FilterArgs::List(v) if v.len() >= 2 => (v[0].as_str(), v[1].as_str()), - _ => bail!("re_replace filter requires [pattern, replacement]"), - }; - let re = Regex::new(pattern).context("re_replace: invalid pattern")?; - Ok(re.replace_all(input, replacement).into_owned()) -} - -/// Split by separator and take the nth element. -fn filter_split(input: &str, args: &FilterArgs) -> Result { - let (sep, idx) = match args { - FilterArgs::List(v) if v.len() >= 2 => { - let idx: usize = v[1].parse().context("split: index is not a number")?; - (v[0].as_str(), idx) - } - _ => bail!("split filter requires [separator, index]"), - }; - let parts: Vec<&str> = input.split(sep).collect(); - Ok(parts.get(idx).unwrap_or(&"").to_string()) -} - -/// Simple string replacement. -fn filter_replace(input: &str, args: &FilterArgs) -> Result { - let (old, new) = match args { - FilterArgs::List(v) if v.len() >= 2 => (v[0].as_str(), v[1].as_str()), - _ => bail!("replace filter requires [old, new]"), - }; - Ok(input.replace(old, new)) -} - -/// Trim whitespace or specified characters. -fn filter_trim(input: &str, args: &FilterArgs) -> Result { - match args { - FilterArgs::Single(chars) => { - let chars: Vec = chars.chars().collect(); - Ok(input.trim_matches(chars.as_slice()).to_owned()) - } - _ => Ok(input.trim().to_owned()), - } -} - -/// Prepend text. -fn filter_prepend(input: &str, args: &FilterArgs) -> Result { - let prefix = match args { - FilterArgs::Single(s) => s.as_str(), - FilterArgs::List(v) if !v.is_empty() => v[0].as_str(), - _ => "", - }; - Ok(format!("{prefix}{input}")) -} - -/// Append text. -fn filter_append(input: &str, args: &FilterArgs) -> Result { - let suffix = match args { - FilterArgs::Single(s) => s.as_str(), - FilterArgs::List(v) if !v.is_empty() => v[0].as_str(), - _ => "", - }; - Ok(format!("{input}{suffix}")) -} - -/// URL-decode. -fn filter_urldecode(input: &str) -> Result { - Ok(urlencoding::decode(input) - .unwrap_or(std::borrow::Cow::Borrowed(input)) - .into_owned()) -} - -/// URL-encode. -fn filter_urlencode(input: &str) -> Result { - Ok(urlencoding::encode(input).into_owned()) -} - -/// Decode HTML entities. -fn filter_htmldecode(input: &str) -> Result { - // Handle common HTML entities - let result = input - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace("'", "'") - .replace(" ", " "); - Ok(result) -} - -/// Encode HTML entities. -fn filter_htmlencode(input: &str) -> Result { - let result = input - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'"); - Ok(result) -} - -/// Parse relative time expressions like "2 hours ago", "Yesterday", "just now". -fn filter_timeago(input: &str) -> Result { - let now = Utc::now(); - let lower = input.trim().to_lowercase(); - - if lower == "just now" || lower == "now" { - return Ok(now.to_rfc3339()); - } - if lower == "yesterday" { - let dt = now - chrono::Duration::days(1); - return Ok(dt.to_rfc3339()); - } - if lower == "today" { - return Ok(now.to_rfc3339()); - } - - // Pattern: "X unit(s) ago" - let re = Regex::new(r"(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago")?; - if let Some(caps) = re.captures(&lower) { - let amount: i64 = caps[1].parse().unwrap_or(0); - let dt = match &caps[2] { - "second" => now - chrono::Duration::seconds(amount), - "minute" => now - chrono::Duration::minutes(amount), - "hour" => now - chrono::Duration::hours(amount), - "day" => now - chrono::Duration::days(amount), - "week" => now - chrono::Duration::weeks(amount), - "month" => now - chrono::Duration::days(amount * 30), - "year" => now - chrono::Duration::days(amount * 365), - _ => now, - }; - return Ok(dt.to_rfc3339()); - } - - // Fallback: try parsing as a unix timestamp - if let Ok(ts) = lower.parse::() { - if let Some(dt) = chrono::DateTime::from_timestamp(ts, 0) { - return Ok(dt.to_rfc3339()); - } - } - - Ok(now.to_rfc3339()) -} - -/// Parse fuzzy date/time strings — handles unix timestamps, relative times, -/// and various date formats commonly found on torrent sites. -fn filter_fuzzytime(input: &str) -> Result { - let trimmed = input.trim(); - - // Unix timestamp - if let Ok(ts) = trimmed.parse::() { - if let Some(dt) = chrono::DateTime::from_timestamp(ts, 0) { - return Ok(dt.to_rfc3339()); - } - } - - // Try common date formats - let formats = &[ - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%d %H:%M", - "%Y-%m-%d", - "%d-%m-%Y %H:%M:%S", - "%d-%m-%Y", - "%d/%m/%Y %H:%M:%S", - "%d/%m/%Y", - "%b %d, %Y", - "%B %d, %Y", - "%d %b %Y", - "%d %B %Y", - ]; - - for fmt in formats { - if let Ok(ndt) = NaiveDateTime::parse_from_str(trimmed, fmt) { - let dt = ndt.and_utc(); - return Ok(dt.to_rfc3339()); - } - if let Ok(nd) = NaiveDate::parse_from_str(trimmed, fmt) { - let dt = nd - .and_hms_opt(0, 0, 0) - .expect("valid time") - .and_utc(); - return Ok(dt.to_rfc3339()); - } - } - - // Try timeago as fallback - filter_timeago(input) -} - -/// Parse a date string using a Go/C#-style format specifier. -fn filter_dateparse(input: &str, args: &FilterArgs) -> Result { - let format_str = match args { - FilterArgs::Single(s) => s.as_str(), - FilterArgs::List(v) if !v.is_empty() => v[0].as_str(), - _ => bail!("dateparse filter requires a format argument"), - }; - - // Convert Go/C#-style format tokens to chrono format - let chrono_fmt = convert_date_format(format_str); - let trimmed = input.trim(); - - if let Ok(ndt) = NaiveDateTime::parse_from_str(trimmed, &chrono_fmt) { - return Ok(ndt.and_utc().to_rfc3339()); - } - if let Ok(nd) = NaiveDate::parse_from_str(trimmed, &chrono_fmt) { - let dt = nd.and_hms_opt(0, 0, 0).expect("valid time").and_utc(); - return Ok(dt.to_rfc3339()); - } - - // Some definitions use format strings like "htt MMM. d" where "htt" is a - // custom token meaning "hours:minutes today". Handle this by trying the - // chrono parse with the current year appended. - let with_year = format!("{trimmed} {}", Utc::now().year()); - let chrono_fmt_year = format!("{chrono_fmt} %Y"); - if let Ok(ndt) = NaiveDateTime::parse_from_str(&with_year, &chrono_fmt_year) { - return Ok(ndt.and_utc().to_rfc3339()); - } - if let Ok(nd) = NaiveDate::parse_from_str(&with_year, &chrono_fmt_year) { - let dt = nd.and_hms_opt(0, 0, 0).expect("valid time").and_utc(); - return Ok(dt.to_rfc3339()); - } - - // Last resort: fuzzytime - filter_fuzzytime(input) -} - -/// Convert Go/C#-style date format tokens to chrono strftime tokens. -fn convert_date_format(fmt: &str) -> String { - fmt.replace("yyyy", "%Y") - .replace("yy", "%y") - .replace("MMMM", "%B") - .replace("MMM", "%b") - .replace("MM", "%m") - .replace("dd", "%d") - .replace("HH", "%H") - .replace("hh", "%I") - .replace("htt", "%I:%M %p") - .replace("mm", "%M") - .replace("ss", "%S") - .replace("tt", "%p") - // Single-letter tokens - .replace(" d ", " %-d ") - .replace(" M ", " %-m ") - .replace(" h ", " %-I ") -} - -/// Replace characters invalid in filenames. -fn filter_validfilename(input: &str) -> Result { - let re = Regex::new(r#"[<>:"/\\|?*]"#)?; - Ok(re.replace_all(input, "_").into_owned()) -} - -/// Remove diacritics/accent marks via Unicode NFKD decomposition. -fn filter_diacritics(input: &str) -> Result { - // Simple approach: filter out combining characters (U+0300-U+036F) - let result: String = input - .chars() - .filter(|c| !('\u{0300}'..='\u{036F}').contains(c)) - .collect(); - Ok(result) -} - -/// Parse a JSON array and join its elements with a separator. -fn filter_jsonjoinarray(input: &str, args: &FilterArgs) -> Result { - let sep = match args { - FilterArgs::Single(s) => s.as_str(), - FilterArgs::List(v) if !v.is_empty() => v[0].as_str(), - _ => ", ", - }; - let arr: Vec = - serde_json::from_str(input).unwrap_or_default(); - let strings: Vec = arr - .iter() - .map(|v| match v { - serde_json::Value::String(s) => s.clone(), - other => other.to_string(), - }) - .collect(); - Ok(strings.join(sep)) -} - -/// Validate input against a set of allowed values. -fn filter_validate(input: &str, args: &FilterArgs) -> Result { - let allowed: Vec<&str> = match args { - FilterArgs::Single(s) => s.split(',').map(str::trim).collect(), - FilterArgs::List(v) => v.iter().map(String::as_str).collect(), - _ => return Ok(input.to_owned()), - }; - if allowed.contains(&input) { - Ok(input.to_owned()) - } else { - Ok(String::new()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_re_replace() { - let result = apply_filter( - "re_replace", - "hello world 123", - &FilterArgs::List(vec!["\\d+".into(), "NUM".into()]), - ) - .unwrap(); - assert_eq!(result, "hello world NUM"); - } - - #[test] - fn test_split() { - // "/torrent/12345/some-name" splits to ["", "torrent", "12345", "some-name"] - let result = apply_filter( - "split", - "/torrent/12345/some-name", - &FilterArgs::List(vec!["/".into(), "1".into()]), - ) - .unwrap(); - assert_eq!(result, "torrent"); - - let result = apply_filter( - "split", - "/torrent/12345/some-name", - &FilterArgs::List(vec!["/".into(), "3".into()]), - ) - .unwrap(); - assert_eq!(result, "some-name"); - } - - #[test] - fn test_replace() { - let result = apply_filter( - "replace", - "hello-world", - &FilterArgs::List(vec!["-".into(), " ".into()]), - ) - .unwrap(); - assert_eq!(result, "hello world"); - } - - #[test] - fn test_append_prepend() { - let result = - apply_filter("prepend", "world", &FilterArgs::Single("hello ".into())).unwrap(); - assert_eq!(result, "hello world"); - let result = - apply_filter("append", "hello", &FilterArgs::Single(" world".into())).unwrap(); - assert_eq!(result, "hello world"); - } - - #[test] - fn test_urlencode_decode() { - let encoded = apply_filter("urlencode", "hello world", &FilterArgs::None).unwrap(); - assert_eq!(encoded, "hello%20world"); - let decoded = apply_filter("urldecode", "hello%20world", &FilterArgs::None).unwrap(); - assert_eq!(decoded, "hello world"); - } - - #[test] - fn test_timeago() { - let result = apply_filter("timeago", "just now", &FilterArgs::None).unwrap(); - assert!(!result.is_empty()); - let result = apply_filter("timeago", "2 hours ago", &FilterArgs::None).unwrap(); - assert!(!result.is_empty()); - } - - #[test] - fn test_regexp() { - let result = apply_filter( - "regexp", - "Size: 1.5 GB", - &FilterArgs::Single(r"(\d+\.?\d*)\s*GB".into()), - ) - .unwrap(); - assert_eq!(result, "1.5"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/lib.rs deleted file mode 100644 index 31b41e03..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/lib.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Cardigann YAML definition engine for StackArr. -//! -//! Interprets Prowlarr-compatible Cardigann YAML indexer definitions, -//! executing searches by building URLs, fetching HTML/JSON responses, -//! and parsing results via CSS selectors / JSON paths. - -pub mod categories; -pub mod definition; -pub mod filters; -pub mod search; -pub mod selector; -pub mod template; - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; - -use crate::definition::{CardigannDefinition, CardigannMetaDefinition}; -use crate::search::CardigannIndexer; - -/// Manages loading and caching of Cardigann YAML definitions. -#[derive(Debug, Clone)] -pub struct CardigannEngine { - /// Directory containing YAML definition files. - definitions_dir: PathBuf, - /// Cached parsed definitions, keyed by definition ID. - definitions: HashMap, -} - -impl CardigannEngine { - /// Create a new engine loading definitions from a directory. - pub fn new(definitions_dir: impl Into) -> Self { - Self { - definitions_dir: definitions_dir.into(), - definitions: HashMap::new(), - } - } - - /// Load all YAML definitions from the definitions directory. - pub fn load_definitions(&mut self) -> Result { - self.definitions.clear(); - - let dir = &self.definitions_dir; - if !dir.exists() { - tracing::warn!(path = %dir.display(), "definitions directory does not exist"); - return Ok(0); - } - - let mut count = 0; - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().is_some_and(|e| e == "yml" || e == "yaml") { - match self.load_definition(&path) { - Ok(def) => { - self.definitions.insert(def.id.clone(), def); - count += 1; - } - Err(e) => { - tracing::warn!( - file = %path.display(), - error = %e, - "failed to parse Cardigann definition" - ); - } - } - } - } - - tracing::info!(count, "loaded Cardigann definitions"); - Ok(count) - } - - /// Load a single definition from a YAML file. - pub fn load_definition(&self, path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display()))?; - let def: CardigannDefinition = serde_yaml::from_str(&content) - .with_context(|| format!("failed to parse {}", path.display()))?; - Ok(def) - } - - /// Get a definition by ID. - pub fn get_definition(&self, id: &str) -> Option<&CardigannDefinition> { - self.definitions.get(id) - } - - /// List all loaded definitions. - pub fn definitions(&self) -> &HashMap { - &self.definitions - } - - /// List only public definitions (type = "public"). - pub fn public_definitions(&self) -> Vec<&CardigannDefinition> { - self.definitions - .values() - .filter(|d| d.privacy.as_deref() == Some("public")) - .collect() - } - - /// Create a `CardigannIndexer` from a loaded definition. - pub fn create_indexer( - &self, - definition_id: &str, - config: HashMap, - indexer_id: i64, - ) -> Result { - let def = self - .definitions - .get(definition_id) - .ok_or_else(|| anyhow::anyhow!("unknown definition: {definition_id}"))?; - - CardigannIndexer::new(def.clone(), config, indexer_id) - } - - /// Fetch the latest definitions from Prowlarr's definition server. - pub async fn fetch_definitions( - definitions_dir: &Path, - version: u32, - ) -> Result { - let url = format!("https://indexers.prowlarr.com/master/{version}"); - tracing::info!(%url, "fetching Cardigann definition index"); - - let client = reqwest::Client::new(); - let index: Vec = client - .get(&url) - .send() - .await? - .json() - .await - .context("failed to parse definition index")?; - - std::fs::create_dir_all(definitions_dir)?; - let mut count = 0; - - for meta in &index { - let def_url = format!("{url}/{}", meta.file); - match client.get(&def_url).send().await { - Ok(resp) if resp.status().is_success() => { - let body = resp.text().await?; - let file_path = definitions_dir.join(format!("{}.yml", meta.file)); - std::fs::write(&file_path, &body)?; - count += 1; - } - Ok(resp) => { - tracing::warn!( - file = %meta.file, - status = %resp.status(), - "failed to fetch definition" - ); - } - Err(e) => { - tracing::warn!(file = %meta.file, error = %e, "failed to fetch definition"); - } - } - - // Small delay to avoid hammering the server - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - - tracing::info!(count, total = index.len(), "fetched Cardigann definitions"); - Ok(count) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/search.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/search.rs deleted file mode 100644 index 63ca7b00..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/search.rs +++ /dev/null @@ -1,622 +0,0 @@ -//! Search execution: load definition → build URL → fetch → parse → return releases. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result, bail}; -use chrono::{DateTime, Utc}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::Value as JsonValue; - -use crate::categories::CategoryMapper; - -/// A release found by a Cardigann indexer search. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CardigannRelease { - pub guid: String, - pub title: String, - pub download_url: Option, - pub info_url: Option, - pub indexer_id: i64, - pub indexer_name: String, - pub size: i64, - pub age_days: i64, - pub publish_date: DateTime, - pub info_hash: Option, - pub magnet_url: Option, - pub seeders: Option, - pub leechers: Option, - pub imdb_id: Option, - pub tvdb_id: Option, - pub tmdb_id: Option, - pub categories: Vec, - pub indexer_flags: Vec, -} -use crate::definition::{CardigannDefinition, FieldBlock, ResponseBlock, SearchPathBlock}; -use crate::filters::{self, apply_filter}; -use crate::selector::{self, Document}; -use crate::template::{QueryContext, TemplateContext}; - -/// A Cardigann-based indexer instance, ready to search. -#[derive(Debug, Clone)] -pub struct CardigannIndexer { - pub definition: CardigannDefinition, - pub config: HashMap, - pub base_url: String, - pub indexer_id: i64, - pub enabled: bool, - http_client: Client, - category_mapper: CategoryMapper, -} - -/// Search query parameters. -#[derive(Debug, Clone, Default)] -pub struct SearchQuery { - pub query: String, - pub categories: Vec, - pub search_type: SearchType, - pub imdb_id: Option, - pub tvdb_id: Option, - pub tmdb_id: Option, - pub season: Option, - pub episode: Option, -} - -#[derive(Debug, Clone, Default)] -pub enum SearchType { - #[default] - Search, - TvSearch, - MovieSearch, - MusicSearch, - BookSearch, -} - -impl CardigannIndexer { - /// Create a new indexer from a definition + user config. - pub fn new( - definition: CardigannDefinition, - config: HashMap, - indexer_id: i64, - ) -> Result { - let base_url = config - .get("baseUrl") - .or_else(|| config.get("baseurl")) - .cloned() - .or_else(|| definition.links.first().cloned()) - .ok_or_else(|| anyhow::anyhow!("no base URL for indexer {}", definition.name))?; - - let category_mapper = CategoryMapper::from_caps(&definition.caps); - - let client = Client::builder() - .timeout(Duration::from_secs(30)) - .cookie_store(true) - .build() - .context("failed to build HTTP client")?; - - Ok(Self { - definition, - config, - base_url, - indexer_id, - enabled: true, - http_client: client, - category_mapper, - }) - } - - /// Execute a search and return normalized releases. - pub async fn search(&self, query: &SearchQuery) -> Result> { - let mut all_releases = Vec::new(); - let ctx = self.build_context(query)?; - - let paths = self.resolve_search_paths(); - - for search_path in &paths { - match self.execute_search_path(search_path, &ctx).await { - Ok(releases) => all_releases.extend(releases), - Err(e) => { - tracing::warn!( - indexer = %self.definition.name, - path = %search_path.path, - error = %e, - "search path failed" - ); - } - } - - // Respect request delay - if let Some(delay) = self.definition.request_delay { - tokio::time::sleep(Duration::from_secs_f64(delay)).await; - } - } - - Ok(all_releases) - } - - /// Build a template context from the search query and indexer config. - fn build_context(&self, query: &SearchQuery) -> Result { - let mut config = self.config.clone(); - - // Ensure sitelink is set - let sitelink = if self.base_url.ends_with('/') { - self.base_url.clone() - } else { - format!("{}/", self.base_url) - }; - config.insert("sitelink".into(), sitelink); - - // Apply default settings from the definition - if let Some(ref settings) = self.definition.settings { - for field in settings { - if !config.contains_key(&field.name) { - if let Some(ref default) = field.default { - let val = match default { - serde_yaml::Value::String(s) => s.clone(), - serde_yaml::Value::Bool(b) => b.to_string(), - serde_yaml::Value::Number(n) => n.to_string(), - _ => String::new(), - }; - config.insert(field.name.clone(), val); - } - } - } - } - - // Apply keywords filters - let mut keywords = query.query.clone(); - if let Some(ref kw_filters) = self.definition.search.keywordsfilters { - for f in kw_filters { - keywords = apply_filter(&f.name, &keywords, &f.args)?; - } - } - - // Map Newznab categories to indexer categories - let categories: Vec = self - .category_mapper - .from_newznab(&query.categories); - - Ok(TemplateContext { - config, - keywords, - categories, - query: QueryContext { - imdbid: query.imdb_id.clone().unwrap_or_default(), - tvdbid: query.tvdb_id.map(|id| id.to_string()).unwrap_or_default(), - tmdbid: query.tmdb_id.map(|id| id.to_string()).unwrap_or_default(), - season: query.season.map(|s| s.to_string()).unwrap_or_default(), - ep: query.episode.map(|e| e.to_string()).unwrap_or_default(), - ..Default::default() - }, - result: HashMap::new(), - true_val: "true".into(), - false_val: String::new(), - }) - } - - /// Resolve which search paths to use. - fn resolve_search_paths(&self) -> Vec { - if let Some(ref paths) = self.definition.search.paths { - paths.clone() - } else if let Some(ref path) = self.definition.search.path { - vec![SearchPathBlock { - path: path.clone(), - method: None, - inputs: None, - queryseparator: None, - categories: None, - inheritinputs: Some(true), - followredirect: None, - response: None, - }] - } else { - Vec::new() - } - } - - /// Execute a single search path and return parsed releases. - async fn execute_search_path( - &self, - search_path: &SearchPathBlock, - ctx: &TemplateContext, - ) -> Result> { - // Expand template in path - let expanded_path = crate::template::expand(&search_path.path, ctx)?; - - // Build full URL - let url = if expanded_path.starts_with("http://") || expanded_path.starts_with("https://") { - expanded_path.clone() - } else { - let base = if self.base_url.ends_with('/') { - &self.base_url - } else { - &format!("{}/", self.base_url) - }; - format!("{base}{expanded_path}") - }; - - // Build query parameters from inputs - let mut url = url::Url::parse(&url).context("failed to parse search URL")?; - - // Merge global inputs - let mut all_inputs: HashMap = HashMap::new(); - if search_path.inheritinputs.unwrap_or(true) { - if let Some(ref inputs) = self.definition.search.inputs { - for (k, v) in inputs { - let expanded = crate::template::expand(v, ctx)?; - all_inputs.insert(k.clone(), expanded); - } - } - } - // Path-specific inputs override - if let Some(ref inputs) = search_path.inputs { - for (k, v) in inputs { - let expanded = crate::template::expand(v, ctx)?; - all_inputs.insert(k.clone(), expanded); - } - } - - // Add inputs to URL query string - if !all_inputs.is_empty() { - let mut pairs = url.query_pairs_mut(); - for (k, v) in &all_inputs { - pairs.append_pair(k, v); - } - } - - tracing::debug!( - indexer = %self.definition.name, - url = %url, - "executing Cardigann search" - ); - - // Execute HTTP request - let method = search_path - .method - .as_deref() - .unwrap_or("get"); - - let response = match method.to_lowercase().as_str() { - "post" => { - self.http_client - .post(url.as_str()) - .form(&all_inputs) - .send() - .await? - } - _ => self.http_client.get(url.as_str()).send().await?, - }; - - let status = response.status(); - if !status.is_success() { - bail!( - "search request failed: {} {}", - status.as_u16(), - status.canonical_reason().unwrap_or("Unknown") - ); - } - - let body = response.text().await?; - - // Determine response type - let is_json = search_path - .response - .as_ref() - .and_then(|r| r.response_type.as_deref()) - .map(|t| t == "json") - .unwrap_or(false); - - if is_json { - self.parse_json_response(&body, ctx).await - } else { - self.parse_html_response(&body, ctx).await - } - } - - /// Parse a JSON response into releases. - async fn parse_json_response( - &self, - body: &str, - base_ctx: &TemplateContext, - ) -> Result> { - let json: JsonValue = - serde_json::from_str(body).context("failed to parse JSON response")?; - - let rows_selector = self - .definition - .search - .rows - .selector - .as_deref() - .unwrap_or("$"); - - let sub_attribute = self.definition.search.rows.attribute.as_deref(); - let multiple = self.definition.search.rows.multiple.unwrap_or(false); - - let rows = selector::select_json_rows(&json, rows_selector, sub_attribute, multiple)?; - - // Check count selector for "no results" - if let Some(ref count_sel) = self.definition.search.rows.count { - if let Some(ref sel) = count_sel.selector { - if let Some(count_val) = crate::selector::json_path_pub(&json, sel) { - let count = match count_val { - JsonValue::Number(n) => n.as_i64().unwrap_or(0), - JsonValue::String(s) => s.parse().unwrap_or(0), - _ => 0, - }; - if count == 0 { - return Ok(Vec::new()); - } - } - } - } - - let mut releases = Vec::new(); - - for row in &rows { - match self.extract_release_from_json(row, base_ctx) { - Ok(Some(release)) => releases.push(release), - Ok(None) => {} - Err(e) => { - tracing::trace!( - indexer = %self.definition.name, - error = %e, - "failed to extract release from JSON row" - ); - } - } - } - - Ok(releases) - } - - /// Parse an HTML response into releases. - async fn parse_html_response( - &self, - body: &str, - base_ctx: &TemplateContext, - ) -> Result> { - let rows_selector = self - .definition - .search - .rows - .selector - .as_deref() - .unwrap_or("tr"); - - let expanded_selector = crate::template::expand(rows_selector, base_ctx)?; - let after = self.definition.search.rows.after; - - let rows = selector::select_html_rows(body, &expanded_selector, after)?; - - let mut releases = Vec::new(); - - for row_html in &rows { - match self.extract_release_from_html(row_html, base_ctx) { - Ok(Some(release)) => releases.push(release), - Ok(None) => {} - Err(e) => { - tracing::trace!( - indexer = %self.definition.name, - error = %e, - "failed to extract release from HTML row" - ); - } - } - } - - Ok(releases) - } - - /// Extract a release from a JSON row. - fn extract_release_from_json( - &self, - row: &JsonValue, - base_ctx: &TemplateContext, - ) -> Result> { - let mut ctx = base_ctx.clone(); - let fields = &self.definition.search.fields; - - // Extract all fields, building up `ctx.result` for cross-references - let mut extracted: HashMap = HashMap::new(); - - for (name, field) in fields { - let value = selector::extract_json_field(row, field, &ctx)?; - if let Some(ref v) = value { - ctx.result.insert(name.clone(), v.clone()); - extracted.insert(name.clone(), v.clone()); - } - } - - self.build_release(&extracted) - } - - /// Extract a release from an HTML row fragment. - fn extract_release_from_html( - &self, - row_html: &str, - base_ctx: &TemplateContext, - ) -> Result> { - let mut ctx = base_ctx.clone(); - let fields = &self.definition.search.fields; - - let mut extracted: HashMap = HashMap::new(); - - for (name, field) in fields { - let value = selector::extract_html_field(row_html, field, &ctx)?; - if let Some(ref v) = value { - ctx.result.insert(name.clone(), v.clone()); - extracted.insert(name.clone(), v.clone()); - } - } - - self.build_release(&extracted) - } - - /// Build a `ReleaseInfo` from extracted field values. - fn build_release(&self, fields: &HashMap) -> Result> { - let title = match fields.get("title") { - Some(t) if !t.is_empty() => t.clone(), - _ => return Ok(None), - }; - - let download_url = fields.get("download").or(fields.get("magnet")).cloned(); - let details_url = fields.get("details").cloned(); - - // Resolve relative URLs - let download_url = download_url.map(|u| self.resolve_url(&u)); - let details_url = details_url.map(|u| self.resolve_url(&u)); - - let size = fields - .get("size") - .and_then(|s| parse_size(s)) - .unwrap_or(0); - - let seeders = fields - .get("seeders") - .and_then(|s| s.replace(',', "").parse().ok()); - - let leechers = fields - .get("leechers") - .and_then(|s| s.replace(',', "").parse().ok()); - - let publish_date = fields - .get("date") - .and_then(|s| parse_date(s)) - .unwrap_or_else(Utc::now); - - let age_days = (Utc::now() - publish_date).num_days(); - - let categories: Vec = fields - .get("category") - .map(|c| self.category_mapper.to_newznab(c)) - .unwrap_or_default(); - - let infohash = fields.get("infohash").cloned(); - let magnet = fields.get("magneturl").or(fields.get("magnet")).cloned(); - let imdb_id = fields.get("imdbid").cloned().filter(|s| !s.is_empty()); - let tvdb_id = fields - .get("tvdbid") - .and_then(|s| s.parse().ok()); - let tmdb_id = fields - .get("tmdbid") - .and_then(|s| s.parse().ok()); - - // Build indexer flags from volume factors - let mut flags = Vec::new(); - if fields.get("downloadvolumefactor").map(String::as_str) == Some("0") { - flags.push("freeleech".to_owned()); - } - - let guid = infohash - .clone() - .or_else(|| download_url.clone()) - .or_else(|| details_url.clone()) - .unwrap_or_else(|| format!("{}-{}", self.definition.id, title)); - - Ok(Some(CardigannRelease { - guid, - title, - download_url, - info_url: details_url, - indexer_id: self.indexer_id, - indexer_name: self.definition.name.clone(), - size, - age_days, - publish_date, - info_hash: infohash, - magnet_url: magnet, - seeders, - leechers, - tvdb_id, - imdb_id, - tmdb_id, - categories, - indexer_flags: flags, - })) - } - - /// Resolve a potentially relative URL against the base URL. - fn resolve_url(&self, url: &str) -> String { - if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("magnet:") { - return url.to_owned(); - } - let base = if self.base_url.ends_with('/') { - &self.base_url - } else { - &self.base_url - }; - if url.starts_with('/') { - // Absolute path - if let Ok(parsed) = url::Url::parse(base) { - return format!("{}://{}{}", parsed.scheme(), parsed.host_str().unwrap_or(""), url); - } - } - format!("{}/{}", base.trim_end_matches('/'), url.trim_start_matches('/')) - } -} - -// --------------------------------------------------------------------------- -// Parsing helpers -// --------------------------------------------------------------------------- - -/// Parse a human-readable size string like "1.5 GB" or raw bytes. -fn parse_size(s: &str) -> Option { - let s = s.trim().replace(',', ""); - - // Already numeric (bytes) - if let Ok(n) = s.parse::() { - return Some(n); - } - if let Ok(n) = s.parse::() { - return Some(n as i64); - } - - // Human-readable - let re = regex::Regex::new(r"(?i)([\d.]+)\s*(bytes?|kb?|mb?|gb?|tb?|kib|mib|gib|tib)") - .ok()?; - let caps = re.captures(&s)?; - let num: f64 = caps[1].parse().ok()?; - let unit = caps[2].to_lowercase(); - - let multiplier: f64 = match unit.as_str() { - "b" | "byte" | "bytes" => 1.0, - "k" | "kb" => 1_000.0, - "kib" => 1_024.0, - "m" | "mb" => 1_000_000.0, - "mib" => 1_048_576.0, - "g" | "gb" => 1_000_000_000.0, - "gib" => 1_073_741_824.0, - "t" | "tb" => 1_000_000_000_000.0, - "tib" => 1_099_511_627_776.0, - _ => 1.0, - }; - - Some((num * multiplier) as i64) -} - -/// Parse a date string — tries RFC3339, unix timestamp, and common formats. -fn parse_date(s: &str) -> Option> { - let s = s.trim(); - - // RFC 3339 - if let Ok(dt) = DateTime::parse_from_rfc3339(s) { - return Some(dt.with_timezone(&Utc)); - } - - // Unix timestamp - if let Ok(ts) = s.parse::() { - return chrono::DateTime::from_timestamp(ts, 0); - } - - // Already parsed by a filter (RFC3339 output from timeago/dateparse) - if let Ok(dt) = DateTime::parse_from_rfc3339(s) { - return Some(dt.with_timezone(&Utc)); - } - - None -} - diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/selector.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/selector.rs deleted file mode 100644 index d3fb5f1b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/selector.rs +++ /dev/null @@ -1,381 +0,0 @@ -//! HTML and JSON selector engine for extracting values from responses. -//! -//! HTML mode uses the `scraper` crate for CSS selectors. -//! JSON mode uses `serde_json::Value` with dotted path access. - -use anyhow::{Context, Result, bail}; -use scraper::{ElementRef, Html, Selector}; -use serde_json::Value as JsonValue; - -use crate::definition::{FieldBlock, FilterBlock, SelectorBlock}; -use crate::filters::{self, apply_filter}; -use crate::template::{self, TemplateContext}; - -/// The parsed response document — either HTML or JSON. -#[derive(Debug, Clone)] -pub enum Document { - Html(String), - Json(JsonValue), -} - -/// A single row context for field extraction. -#[derive(Debug, Clone)] -pub enum RowContext<'a> { - HtmlElement(String), // Serialized outer HTML of the row element - JsonObject(&'a JsonValue), -} - -// --------------------------------------------------------------------------- -// HTML extraction -// --------------------------------------------------------------------------- - -/// Select all row elements from an HTML document. -pub fn select_html_rows(html: &str, selector: &str, after: Option) -> Result> { - let doc = Html::parse_document(html); - let sel = parse_selector(selector)?; - - let mut rows: Vec = doc - .select(&sel) - .map(|el| el.html()) - .collect(); - - // Skip `after` rows from the beginning - if let Some(skip) = after { - if skip < rows.len() { - rows = rows[skip..].to_vec(); - } else { - rows.clear(); - } - } - - Ok(rows) -} - -/// Extract a field value from an HTML row fragment. -pub fn extract_html_field( - row_html: &str, - field: &FieldBlock, - ctx: &TemplateContext, -) -> Result> { - // If `text` is set, use template expansion (no CSS selection) - if let Some(ref text) = field.text { - let val = template::expand(text, ctx)?; - return Ok(Some(apply_field_filters(&val, &field.filters, ctx)?)); - } - - let selector_str = match &field.selector { - Some(s) => template::expand(s, ctx)?, - None => return Ok(field.default.clone()), - }; - - let fragment = Html::parse_fragment(row_html); - - // Handle `..selector` (parent-level access for JSON rows nested via `multiple`) - // In HTML mode, `..` means "select from the row itself" - let effective_selector = selector_str.trim_start_matches(".."); - - let sel = match parse_selector(effective_selector) { - Ok(s) => s, - Err(_) if field.optional.unwrap_or(false) => return Ok(field.default.clone()), - Err(e) => return Err(e), - }; - - let element = fragment.select(&sel).next(); - - match element { - Some(el) => { - let raw = if let Some(ref attr) = field.attribute { - el.value().attr(attr).unwrap_or("").to_owned() - } else { - // If `remove` is specified, we'd need to remove child elements first - // For now, just get the text content - el.text().collect::>().join("").trim().to_owned() - }; - let filtered = apply_field_filters(&raw, &field.filters, ctx)?; - if filtered.is_empty() { - Ok(field.default.clone().or(Some(filtered))) - } else { - Ok(Some(filtered)) - } - } - None => { - if field.optional.unwrap_or(false) { - Ok(field.default.clone()) - } else { - Ok(Some(String::new())) - } - } - } -} - -// --------------------------------------------------------------------------- -// JSON extraction -// --------------------------------------------------------------------------- - -/// Select all row elements from a JSON response. -pub fn select_json_rows( - json: &JsonValue, - selector: &str, - sub_attribute: Option<&str>, - multiple: bool, -) -> Result> { - let rows_val = json_path(json, selector); - - let base_rows = match rows_val { - Some(JsonValue::Array(arr)) => arr.clone(), - Some(val) => vec![val.clone()], - None => return Ok(Vec::new()), - }; - - // If `attribute` and `multiple` are set, each row's sub-array becomes - // individual rows (with parent fields accessible via `..`). - if let Some(attr) = sub_attribute { - if multiple { - let mut expanded = Vec::new(); - for parent in &base_rows { - if let Some(JsonValue::Array(children)) = json_path(parent, attr) { - for child in children { - // Merge parent into child as `__parent` - let mut merged = child.clone(); - if let JsonValue::Object(ref mut map) = merged { - map.insert("__parent".into(), parent.clone()); - } - expanded.push(merged); - } - } - } - return Ok(expanded); - } - } - - Ok(base_rows) -} - -/// Extract a field value from a JSON row object. -pub fn extract_json_field( - row: &JsonValue, - field: &FieldBlock, - ctx: &TemplateContext, -) -> Result> { - // If `text` is set, use template expansion - if let Some(ref text) = field.text { - let val = template::expand(text, ctx)?; - return Ok(Some(apply_field_filters(&val, &field.filters, ctx)?)); - } - - let selector = match &field.selector { - Some(s) => s.as_str(), - None => return Ok(field.default.clone()), - }; - - // Handle `..field` — access parent object - let (target, path) = if let Some(stripped) = selector.strip_prefix("..") { - let parent = row.get("__parent").unwrap_or(row); - (parent, stripped) - } else { - (row, selector) - }; - - let value = json_path(target, path); - - let raw = match value { - Some(JsonValue::String(s)) => s.clone(), - Some(JsonValue::Number(n)) => n.to_string(), - Some(JsonValue::Bool(b)) => b.to_string(), - Some(JsonValue::Null) => String::new(), - Some(other) => other.to_string(), - None => { - if field.optional.unwrap_or(false) { - return Ok(field.default.clone()); - } - String::new() - } - }; - - // Apply case mapping if present - let after_case = if let Some(ref case_map) = field.case_map { - let mut matched = None; - for (pattern, replacement) in case_map { - if pattern == &raw || pattern == "*" { - matched = Some(match replacement { - serde_yaml::Value::String(s) => s.clone(), - serde_yaml::Value::Number(n) => n.to_string(), - other => format!("{other:?}"), - }); - if pattern != "*" { - break; - } - } - } - matched.unwrap_or(raw) - } else { - raw - }; - - let filtered = apply_field_filters(&after_case, &field.filters, ctx)?; - Ok(Some(filtered)) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Apply a chain of filters to a value. -fn apply_field_filters( - input: &str, - filters: &Option>, - ctx: &TemplateContext, -) -> Result { - let mut val = input.to_owned(); - if let Some(filter_chain) = filters { - for f in filter_chain { - // Filter args may contain template expressions - let resolved_args = resolve_filter_args(&f.args, ctx)?; - val = apply_filter(&f.name, &val, &resolved_args)?; - } - } - Ok(val) -} - -/// Resolve template expressions within filter arguments. -fn resolve_filter_args( - args: &crate::definition::FilterArgs, - ctx: &TemplateContext, -) -> Result { - use crate::definition::FilterArgs; - - match args { - FilterArgs::None => Ok(FilterArgs::None), - FilterArgs::Single(s) => { - let expanded = template::expand(s, ctx)?; - Ok(FilterArgs::Single(expanded)) - } - FilterArgs::List(items) => { - let expanded: Result> = items - .iter() - .map(|s| template::expand(s, ctx)) - .collect(); - Ok(FilterArgs::List(expanded?)) - } - } -} - -/// Navigate a dotted JSON path (e.g., `data.movies` or `$[0].id`). -fn json_path<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> { - let path = path.trim().trim_start_matches('$'); - if path.is_empty() { - return Some(value); - } - let path = path.trim_start_matches('.'); - - let mut current = value; - for segment in path.split('.') { - if segment.is_empty() { - continue; - } - // Handle array index: `[0]` - if let Some(idx_str) = segment.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { - if let Ok(idx) = idx_str.parse::() { - current = current.get(idx)?; - continue; - } - } - // Object key access - current = current.get(segment)?; - } - - Some(current) -} - -/// Public JSON path accessor for use by search.rs count checks. -pub fn json_path_pub<'a>(value: &'a JsonValue, path: &str) -> Option<&'a JsonValue> { - json_path(value, path) -} - -/// Parse a CSS selector, handling common Prowlarr patterns. -fn parse_selector(selector: &str) -> Result { - let selector = selector.trim(); - - // Handle `$` prefix (JSON-style root selector used in some HTML defs) - let selector = selector.trim_start_matches('$'); - if selector.is_empty() { - // `$` alone means "all elements" — use `*` - return Selector::parse("*") - .map_err(|e| anyhow::anyhow!("invalid CSS selector '*': {e:?}")); - } - - Selector::parse(selector) - .map_err(|e| anyhow::anyhow!("invalid CSS selector '{selector}': {e:?}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn json_path_simple() { - let json: JsonValue = serde_json::json!({ - "data": { - "movies": [ - {"title": "Ubuntu Movie", "year": 2024} - ], - "movie_count": 1 - } - }); - - let movies = json_path(&json, "data.movies"); - assert!(movies.is_some()); - assert!(movies.unwrap().is_array()); - - let count = json_path(&json, "data.movie_count"); - assert_eq!(count, Some(&JsonValue::Number(1.into()))); - } - - #[test] - fn json_extract_field() { - let row: JsonValue = serde_json::json!({ - "name": "Ubuntu 24.04", - "size": 1234567890, - "seeders": 42 - }); - - let field = FieldBlock { - selector: Some("name".into()), - optional: None, - default: None, - text: None, - attribute: None, - remove: None, - filters: None, - case_map: None, - }; - - let ctx = TemplateContext::default(); - let result = extract_json_field(&row, &field, &ctx).unwrap(); - assert_eq!(result, Some("Ubuntu 24.04".to_owned())); - } - - #[test] - fn json_extract_with_case_map() { - let row: JsonValue = serde_json::json!({"quality": "1080p"}); - let mut case_map = indexmap::IndexMap::new(); - case_map.insert("720p".into(), serde_yaml::Value::Number(45.into())); - case_map.insert("1080p".into(), serde_yaml::Value::Number(44.into())); - case_map.insert("*".into(), serde_yaml::Value::Number(45.into())); - - let field = FieldBlock { - selector: Some("quality".into()), - case_map: Some(case_map), - optional: None, - default: None, - text: None, - attribute: None, - remove: None, - filters: None, - }; - - let ctx = TemplateContext::default(); - let result = extract_json_field(&row, &field, &ctx).unwrap(); - assert_eq!(result, Some("44".to_owned())); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/template.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/template.rs deleted file mode 100644 index de79415e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-cardigann/src/template.rs +++ /dev/null @@ -1,494 +0,0 @@ -//! Go-template-like expression evaluator for Cardigann YAML paths and values. -//! -//! Supports the subset of Go templates actually used across Prowlarr's 549 -//! Cardigann definitions: variable interpolation, conditionals, range, and -//! a small set of functions (join, or, eq, and, not). - -use std::collections::HashMap; - -use anyhow::{Result, bail}; - -/// Context variables available during template expansion. -#[derive(Debug, Clone, Default)] -pub struct TemplateContext { - /// User settings + `sitelink` (the active base URL). - pub config: HashMap, - /// The search keywords (already filtered by `keywordsfilters`). - pub keywords: String, - /// Category IDs to search (as strings). - pub categories: Vec, - /// Search query parameters. - pub query: QueryContext, - /// Previously-extracted field values (for cross-references like `.Result._id`). - pub result: HashMap, - /// Boolean constants. - pub true_val: String, - pub false_val: String, -} - -/// Search query parameters. -#[derive(Debug, Clone, Default)] -pub struct QueryContext { - pub imdbid: String, - pub tvdbid: String, - pub tmdbid: String, - pub season: String, - pub ep: String, - pub album: String, - pub artist: String, - pub label: String, - pub track: String, - pub year: String, -} - -impl TemplateContext { - /// Resolve a dotted variable path like `.Config.sitelink` or `.Keywords`. - pub fn resolve(&self, path: &str) -> String { - let path = path.trim().trim_start_matches('.'); - let parts: Vec<&str> = path.splitn(2, '.').collect(); - - match parts[0].to_lowercase().as_str() { - "config" if parts.len() == 2 => self - .config - .get(parts[1]) - .cloned() - .unwrap_or_default(), - "keywords" => self.keywords.clone(), - "categories" => self.categories.join(","), - "query" if parts.len() == 2 => self.resolve_query(parts[1]), - "result" if parts.len() == 2 => self - .result - .get(parts[1]) - .cloned() - .unwrap_or_default(), - "true" => "true".to_owned(), - "false" => "false".to_owned(), - "today" if parts.len() == 2 => { - let now = chrono::Utc::now(); - match parts[1].to_lowercase().as_str() { - "year" => now.format("%Y").to_string(), - "month" => now.format("%m").to_string(), - "day" => now.format("%d").to_string(), - _ => String::new(), - } - } - _ => String::new(), - } - } - - fn resolve_query(&self, field: &str) -> String { - match field.to_uppercase().as_str() { - "IMDBID" => self.query.imdbid.clone(), - "TVDBID" => self.query.tvdbid.clone(), - "TMDBID" => self.query.tmdbid.clone(), - "SEASON" => self.query.season.clone(), - "EP" | "EPISODE" => self.query.ep.clone(), - "ALBUM" => self.query.album.clone(), - "ARTIST" => self.query.artist.clone(), - "LABEL" => self.query.label.clone(), - "TRACK" => self.query.track.clone(), - "YEAR" => self.query.year.clone(), - _ => String::new(), - } - } - - /// Check if a variable is "truthy" (non-empty, not "false", not "0"). - pub fn is_truthy(&self, path: &str) -> bool { - let val = self.resolve(path); - !val.is_empty() && val != "false" && val != "0" - } -} - -/// Expand a Go-template string against the given context. -/// -/// Handles: `{{ .Var }}`, `{{ if .X }}...{{ else }}...{{ end }}`, -/// `{{ range .Categories }}...{{ end }}`, `{{ join .X "," }}`, -/// `{{ or .A .B }}`, `{{ eq .A "lit" }}`, `{{ and .A .B }}`. -pub fn expand(template: &str, ctx: &TemplateContext) -> Result { - let mut output = String::with_capacity(template.len()); - let mut pos = 0; - while pos < template.len() { - if let Some(start) = template[pos..].find("{{") { - // Append literal text before the tag - output.push_str(&template[pos..pos + start]); - let tag_start = pos + start + 2; - - // Find matching }} - let end = template[tag_start..] - .find("}}") - .map(|e| tag_start + e) - .ok_or_else(|| anyhow::anyhow!("unclosed template tag"))?; - - let tag = template[tag_start..end].trim(); - pos = end + 2; - - // Handle different tag types - if let Some(rest) = tag.strip_prefix("if ") { - let (if_body, else_body, new_pos) = parse_if_block(template, pos)?; - pos = new_pos; - let condition = evaluate_condition(rest.trim(), ctx); - if condition { - output.push_str(&expand(&if_body, ctx)?); - } else if let Some(eb) = else_body { - output.push_str(&expand(&eb, ctx)?); - } - } else if let Some(rest) = tag.strip_prefix("range ") { - let (body, new_pos) = parse_range_block(template, pos)?; - pos = new_pos; - let var_path = rest.trim(); - let items = resolve_iterable(var_path, ctx); - for item in &items { - let item_template = body.replace("{{ . }}", item).replace("{{.}}", item); - output.push_str(&expand(&item_template, ctx)?); - } - } else if let Some(rest) = tag.strip_prefix("join ") { - let (var, sep) = parse_join_args(rest)?; - let items = resolve_iterable(&var, ctx); - output.push_str(&items.join(&sep)); - } else if tag.starts_with("or ") { - let result = evaluate_or(&tag[3..], ctx); - output.push_str(&result); - } else if tag.starts_with("eq ") { - let result = evaluate_eq(&tag[3..], ctx); - output.push_str(if result { "true" } else { "false" }); - } else if tag.starts_with("and ") { - let result = evaluate_and(&tag[4..], ctx); - output.push_str(if result { "true" } else { "" }); - } else if tag.starts_with("not ") { - let val = ctx.resolve(tag[4..].trim()); - output.push_str(if val.is_empty() || val == "false" || val == "0" { - "true" - } else { - "" - }); - } else if tag == "else" || tag == "end" { - // Should have been consumed by if/range parsing - // If we hit this, there's a nesting error — just skip - } else { - // Simple variable interpolation: {{ .Config.sitelink }} - let val = ctx.resolve(tag); - output.push_str(&val); - } - } else { - // No more tags — append rest of string - output.push_str(&template[pos..]); - break; - } - } - - Ok(output) -} - -/// Parse an `if` block, returning (if_body, optional else_body, position after `{{ end }}`). -fn parse_if_block(template: &str, start: usize) -> Result<(String, Option, usize)> { - let mut depth = 1; - let mut pos = start; - let mut else_pos = None; - - while pos < template.len() && depth > 0 { - if let Some(tag_start) = template[pos..].find("{{") { - let abs_start = pos + tag_start + 2; - if let Some(tag_end) = template[abs_start..].find("}}") { - let abs_end = abs_start + tag_end; - let tag = template[abs_start..abs_end].trim(); - - if tag.starts_with("if ") { - depth += 1; - } else if tag == "end" { - depth -= 1; - if depth == 0 { - let if_body = if let Some(ep) = else_pos { - template[start..ep].to_owned() - } else { - template[start..pos + tag_start].to_owned() - }; - let else_body = else_pos.map(|ep| { - // ep points to start of {{ else }}, skip past it - let after_else = template[ep..].find("}}").unwrap() + ep + 2; - template[after_else..pos + tag_start].to_owned() - }); - return Ok((if_body, else_body, abs_end + 2)); - } - } else if tag == "else" && depth == 1 { - else_pos = Some(pos + tag_start); - } - - pos = abs_end + 2; - } else { - break; - } - } else { - break; - } - } - - bail!("unterminated if block") -} - -/// Parse a `range` block, returning (body, position after `{{ end }}`). -fn parse_range_block(template: &str, start: usize) -> Result<(String, usize)> { - let mut depth = 1; - let mut pos = start; - - while pos < template.len() && depth > 0 { - if let Some(tag_start) = template[pos..].find("{{") { - let abs_start = pos + tag_start + 2; - if let Some(tag_end) = template[abs_start..].find("}}") { - let abs_end = abs_start + tag_end; - let tag = template[abs_start..abs_end].trim(); - - if tag.starts_with("range ") || tag.starts_with("if ") { - depth += 1; - } else if tag == "end" { - depth -= 1; - if depth == 0 { - let body = template[start..pos + tag_start].to_owned(); - return Ok((body, abs_end + 2)); - } - } - pos = abs_end + 2; - } else { - break; - } - } else { - break; - } - } - - bail!("unterminated range block") -} - -/// Evaluate a condition expression for `{{ if ... }}`. -fn evaluate_condition(expr: &str, ctx: &TemplateContext) -> bool { - let expr = expr.trim(); - - // {{ if and .A .B }} - if let Some(rest) = expr.strip_prefix("and ") { - return evaluate_and(rest, ctx); - } - // {{ if or .A .B }} - if let Some(rest) = expr.strip_prefix("or ") { - let result = evaluate_or(rest, ctx); - return !result.is_empty() && result != "false" && result != "0"; - } - // {{ if eq .A "literal" }} - if let Some(rest) = expr.strip_prefix("eq ") { - return evaluate_eq(rest, ctx); - } - // {{ if not .A }} - if let Some(rest) = expr.strip_prefix("not ") { - return !ctx.is_truthy(rest.trim()); - } - // Simple variable truthiness - ctx.is_truthy(expr) -} - -/// Evaluate `and .A .B` — returns true if all args are truthy. -fn evaluate_and(args_str: &str, ctx: &TemplateContext) -> bool { - // Handle nested function calls: and (.A) (eq .B "val") - let args = parse_function_args(args_str); - args.iter().all(|arg| { - let arg = arg.trim().trim_start_matches('(').trim_end_matches(')'); - evaluate_condition(arg, ctx) - }) -} - -/// Evaluate `or .A .B` — returns first truthy value (as a string). -fn evaluate_or(args_str: &str, ctx: &TemplateContext) -> String { - let args = parse_function_args(args_str); - for arg in &args { - let arg = arg.trim().trim_start_matches('(').trim_end_matches(')'); - let val = ctx.resolve(arg); - if !val.is_empty() && val != "false" && val != "0" { - return val; - } - } - String::new() -} - -/// Evaluate `eq .A "literal"` or `eq .A .B`. -fn evaluate_eq(args_str: &str, ctx: &TemplateContext) -> bool { - let args = parse_function_args(args_str); - if args.len() < 2 { - return false; - } - let left = resolve_or_literal(&args[0], ctx); - let right = resolve_or_literal(&args[1], ctx); - left == right -} - -/// Parse space-separated arguments, respecting quoted strings and parenthesised groups. -fn parse_function_args(input: &str) -> Vec { - let mut args = Vec::new(); - let mut current = String::new(); - let mut in_quotes = false; - let mut quote_char = '"'; - let mut paren_depth = 0; - - for ch in input.chars() { - match ch { - '"' | '\'' if paren_depth == 0 => { - // Keep quotes in the output so resolve_or_literal can detect literals - current.push(ch); - if in_quotes && ch == quote_char { - in_quotes = false; - } else if !in_quotes { - in_quotes = true; - quote_char = ch; - } - } - '(' if !in_quotes => { - paren_depth += 1; - current.push(ch); - } - ')' if !in_quotes => { - paren_depth -= 1; - current.push(ch); - } - ' ' if !in_quotes && paren_depth == 0 => { - let trimmed = current.trim().to_owned(); - if !trimmed.is_empty() { - args.push(trimmed); - } - current.clear(); - } - _ => { - current.push(ch); - } - } - } - - let trimmed = current.trim().to_owned(); - if !trimmed.is_empty() { - args.push(trimmed); - } - - args -} - -/// Resolve a value that could be a dotted variable path or a string literal. -fn resolve_or_literal(val: &str, ctx: &TemplateContext) -> String { - let val = val.trim(); - // Quoted string literal - if (val.starts_with('"') && val.ends_with('"')) - || (val.starts_with('\'') && val.ends_with('\'')) - { - return val[1..val.len() - 1].to_owned(); - } - // Parenthesised expression - if val.starts_with('(') && val.ends_with(')') { - let inner = &val[1..val.len() - 1]; - if let Some(rest) = inner.strip_prefix("eq ") { - return if evaluate_eq(rest, ctx) { - "true".to_owned() - } else { - "false".to_owned() - }; - } - return ctx.resolve(inner); - } - // Variable reference - ctx.resolve(val) -} - -/// Parse `join .Categories ","` arguments. -fn parse_join_args(input: &str) -> Result<(String, String)> { - let args = parse_function_args(input); - if args.len() < 2 { - bail!("join requires a variable and separator"); - } - let var = args[0].clone(); - let sep = args[1].trim_matches('"').trim_matches('\'').to_owned(); - Ok((var, sep)) -} - -/// Resolve a variable path to a list of items for `range` or `join`. -fn resolve_iterable(path: &str, ctx: &TemplateContext) -> Vec { - let path = path.trim().trim_start_matches('.'); - match path.to_lowercase().as_str() { - "categories" => ctx.categories.clone(), - _ => { - let val = ctx.resolve(path); - if val.is_empty() { - Vec::new() - } else { - vec![val] - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_ctx() -> TemplateContext { - let mut ctx = TemplateContext::default(); - ctx.config.insert("sitelink".into(), "https://example.com/".into()); - ctx.config.insert("apiurl".into(), "apibay.org".into()); - ctx.keywords = "ubuntu".into(); - ctx.categories = vec!["100".into(), "200".into()]; - ctx.query.imdbid = "tt1234567".into(); - ctx.result.insert("_id".into(), "42".into()); - ctx - } - - #[test] - fn simple_variable() { - let ctx = test_ctx(); - let result = expand("{{ .Keywords }}", &ctx).unwrap(); - assert_eq!(result, "ubuntu"); - } - - #[test] - fn config_variable() { - let ctx = test_ctx(); - let result = expand("https://{{ .Config.apiurl }}/search", &ctx).unwrap(); - assert_eq!(result, "https://apibay.org/search"); - } - - #[test] - fn if_else() { - let ctx = test_ctx(); - let result = - expand("{{ if .Keywords }}has_kw{{ else }}no_kw{{ end }}", &ctx).unwrap(); - assert_eq!(result, "has_kw"); - - let mut ctx2 = test_ctx(); - ctx2.keywords = String::new(); - let result = - expand("{{ if .Keywords }}has_kw{{ else }}no_kw{{ end }}", &ctx2).unwrap(); - assert_eq!(result, "no_kw"); - } - - #[test] - fn if_with_query() { - let ctx = test_ctx(); - let tpl = "{{ if .Query.IMDBID }}{{ .Query.IMDBID }}{{ else }}{{ .Keywords }}{{ end }}"; - let result = expand(tpl, &ctx).unwrap(); - assert_eq!(result, "tt1234567"); - } - - #[test] - fn join_categories() { - let ctx = test_ctx(); - let result = expand("{{ join .Categories \",\" }}", &ctx).unwrap(); - assert_eq!(result, "100,200"); - } - - #[test] - fn result_reference() { - let ctx = test_ctx(); - let result = expand("{{ .Config.sitelink }}description.php?id={{ .Result._id }}", &ctx).unwrap(); - assert_eq!(result, "https://example.com/description.php?id=42"); - } - - #[test] - fn eq_function() { - let mut ctx = test_ctx(); - ctx.result.insert("_type".into(), "web".into()); - let tpl = "{{ if eq .Result._type \"web\" }}WEBRip{{ else }}BRRip{{ end }}"; - let result = expand(tpl, &ctx).unwrap(); - assert_eq!(result, "WEBRip"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/Cargo.toml deleted file mode 100644 index d4bccf6d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "stackarr-core" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[features] -default = [] -testing = [] - -[dependencies] -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -toml = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -arc-swap = { workspace = true } -tokio = { workspace = true } -argon2 = "0.5" -sha2 = { workspace = true } -hex = { workspace = true } -rand = { workspace = true } -base64 = { workspace = true } - -[dev-dependencies] -uuid = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/auth.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/auth.rs deleted file mode 100644 index f627e9fa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/auth.rs +++ /dev/null @@ -1,86 +0,0 @@ -use argon2::password_hash::rand_core::OsRng; -use argon2::password_hash::SaltString; -use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use rand::Rng; -use sha2::{Digest, Sha256}; - -/// Hash a password using Argon2id. -pub fn hash_password(password: &str) -> crate::Result { - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - let hash = argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| crate::Error::Other(anyhow::anyhow!("password hash failed: {e}")))?; - Ok(hash.to_string()) -} - -/// Verify a password against a stored Argon2id hash. -pub fn verify_password(password: &str, hash: &str) -> crate::Result { - let parsed = PasswordHash::new(hash) - .map_err(|e| crate::Error::Other(anyhow::anyhow!("invalid password hash: {e}")))?; - Ok(Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .is_ok()) -} - -/// Generate a cryptographically secure session token (32 random bytes, base64url-encoded). -pub fn generate_session_token() -> String { - let mut bytes = [0u8; 32]; - rand::rng().fill(&mut bytes); - URL_SAFE_NO_PAD.encode(bytes) -} - -/// Hash a session token with SHA-256 for storage (hex-encoded). -pub fn hash_token(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hex::encode(hasher.finalize()) -} - -/// Generate an 8-character alphanumeric invite code. -pub fn generate_invite_code() -> String { - const CHARSET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - let mut rng = rand::rng(); - (0..8) - .map(|_| { - let idx = rng.random_range(0..CHARSET.len()); - CHARSET[idx] as char - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hash_and_verify_password() { - let hash = hash_password("hunter2").unwrap(); - assert!(verify_password("hunter2", &hash).unwrap()); - assert!(!verify_password("wrong", &hash).unwrap()); - } - - #[test] - fn test_generate_session_token() { - let token = generate_session_token(); - assert!(!token.is_empty()); - assert!(token.len() > 20); - } - - #[test] - fn test_hash_token_deterministic() { - let h1 = hash_token("test-token"); - let h2 = hash_token("test-token"); - assert_eq!(h1, h2); - assert_ne!(h1, hash_token("other")); - } - - #[test] - fn test_generate_invite_code() { - let code = generate_invite_code(); - assert_eq!(code.len(), 8); - assert!(code.chars().all(|c| c.is_ascii_alphanumeric())); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/config.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/config.rs deleted file mode 100644 index 0392f4af..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/config.rs +++ /dev/null @@ -1,481 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppConfig { - pub general: GeneralConfig, - pub database: DatabaseConfig, - pub auth: AuthConfig, - #[serde(default)] - pub torrent: TorrentConfig, - #[serde(default)] - pub usenet: UsenetConfig, - #[serde(default)] - pub indexarr: IndexarrConfig, - #[serde(default)] - pub naming: NamingConfig, - #[serde(default)] - pub streaming: StreamingConfig, - #[serde(default)] - pub bootstrap: BootstrapConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeneralConfig { - #[serde(default = "default_instance_name")] - pub instance_name: String, - #[serde(default = "default_bind_addr")] - pub bind_addr: String, - #[serde(default = "default_port")] - pub port: u16, - #[serde(default = "default_data_dir")] - pub data_dir: PathBuf, - #[serde(default = "default_log_level")] - pub log_level: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DatabaseConfig { - pub url: String, - #[serde(default = "default_max_connections")] - pub max_connections: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AuthConfig { - #[serde(default = "default_auth_method")] - pub method: String, - pub api_key: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TorrentConfig { - #[serde(default)] - pub enabled: bool, - pub download_dir: Option, - pub complete_dir: Option, - #[serde(default = "default_listen_port")] - pub listen_port: u16, - #[serde(default = "default_true")] - pub dht_enabled: bool, - #[serde(default = "default_peer_limit")] - pub peer_limit: usize, - #[serde(default)] - pub upload_limit_bps: u64, - #[serde(default)] - pub download_limit_bps: u64, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct UsenetConfig { - #[serde(default)] - pub enabled: bool, - pub incomplete_dir: Option, - pub complete_dir: Option, - #[serde(default = "default_max_active")] - pub max_active_downloads: usize, - #[serde(default)] - pub servers: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UsenetServerConfig { - pub name: String, - pub host: String, - #[serde(default = "default_nntp_port")] - pub port: u16, - #[serde(default = "default_true")] - pub ssl: bool, - pub username: Option, - pub password: Option, - #[serde(default = "default_connections")] - pub connections: u16, - #[serde(default)] - pub priority: u8, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct IndexarrConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_indexarr_url")] - pub url: String, - pub api_key: Option, - #[serde(default = "default_indexarr_mode")] - pub mode: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StreamingConfig { - #[serde(default)] - pub enabled: bool, - pub transcode_dir: Option, - #[serde(default = "default_ffmpeg_path")] - pub ffmpeg_path: String, - #[serde(default = "default_ffprobe_path")] - pub ffprobe_path: String, - #[serde(default)] - pub hwaccel: HwAccelConfig, - #[serde(default = "default_segment_duration")] - pub segment_duration_secs: u32, - #[serde(default = "default_max_sessions")] - pub max_concurrent_sessions: usize, -} - -impl Default for StreamingConfig { - fn default() -> Self { - Self { - enabled: false, - transcode_dir: None, - ffmpeg_path: default_ffmpeg_path(), - ffprobe_path: default_ffprobe_path(), - hwaccel: HwAccelConfig::default(), - segment_duration_secs: default_segment_duration(), - max_concurrent_sessions: default_max_sessions(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct HwAccelConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default = "default_hwaccel_type")] - pub accel_type: String, - pub device: Option, -} - -impl Default for HwAccelConfig { - fn default() -> Self { - Self { - enabled: false, - accel_type: default_hwaccel_type(), - device: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NamingConfig { - #[serde(default)] - pub series: SeriesNaming, - #[serde(default)] - pub movie: MovieNaming, -} - -impl Default for NamingConfig { - fn default() -> Self { - Self { - series: SeriesNaming::default(), - movie: MovieNaming::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SeriesNaming { - #[serde(default = "default_true")] - pub rename: bool, - #[serde(default = "default_series_standard_format")] - pub standard: String, - #[serde(default = "default_series_daily_format")] - pub daily: String, - #[serde(default = "default_series_anime_format")] - pub anime: String, - #[serde(default = "default_season_folder_format")] - pub season_folder: String, -} - -impl Default for SeriesNaming { - fn default() -> Self { - Self { - rename: true, - standard: default_series_standard_format(), - daily: default_series_daily_format(), - anime: default_series_anime_format(), - season_folder: default_season_folder_format(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MovieNaming { - #[serde(default = "default_true")] - pub rename: bool, - #[serde(default = "default_movie_format")] - pub standard: String, - #[serde(default = "default_movie_folder_format")] - pub folder: String, -} - -impl Default for MovieNaming { - fn default() -> Self { - Self { - rename: true, - standard: default_movie_format(), - folder: default_movie_folder_format(), - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct BootstrapConfig { - #[serde(default)] - pub enabled: bool, - /// URL of the bootstrap node (e.g., "https://bootstrap.example.com") - pub url: Option, - /// Token to authenticate with the bootstrap node - pub token: Option, - /// Port to advertise (defaults to general.port) - pub advertise_port: Option, - /// Whether to use UPnP to forward the advertise port - #[serde(default)] - pub upnp_enabled: bool, -} - -/// Modules the user has chosen to enable (persisted in DB, set at first-boot). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct EnabledModules { - pub tv_management: bool, - pub movie_management: bool, - pub torrent_embedded: bool, - pub usenet_embedded: bool, - pub torrent_external: bool, - pub usenet_external: bool, - pub indexarr_sidecar: bool, - pub external_indexers: bool, - pub plex_integration: bool, - pub notifications: bool, - pub streaming: bool, - pub remote_access: bool, -} - -// --- Default value functions --- - -fn default_instance_name() -> String { - "StackArr".to_string() -} -fn default_bind_addr() -> String { - "0.0.0.0".to_string() -} -fn default_port() -> u16 { - 8989 -} -fn default_data_dir() -> PathBuf { - PathBuf::from("/config") -} -fn default_log_level() -> String { - "info".to_string() -} -fn default_max_connections() -> u32 { - 20 -} -fn default_auth_method() -> String { - "forms".to_string() -} -fn default_listen_port() -> u16 { - 6881 -} -fn default_true() -> bool { - true -} -fn default_peer_limit() -> usize { - 200 -} -fn default_max_active() -> usize { - 3 -} -fn default_nntp_port() -> u16 { - 563 -} -fn default_connections() -> u16 { - 8 -} -fn default_indexarr_url() -> String { - "http://indexarr:8080".to_string() -} -fn default_indexarr_mode() -> String { - "peer".to_string() -} -fn default_ffmpeg_path() -> String { - "ffmpeg".to_string() -} -fn default_ffprobe_path() -> String { - "ffprobe".to_string() -} -fn default_segment_duration() -> u32 { - 6 -} -fn default_max_sessions() -> usize { - 3 -} -fn default_hwaccel_type() -> String { - "qsv".to_string() -} -fn default_series_standard_format() -> String { - "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]".to_string() -} -fn default_series_daily_format() -> String { - "{Series Title} - {Air-Date} - {Episode Title} [{Quality Title}]".to_string() -} -fn default_series_anime_format() -> String { - "{Series Title} - S{season:00}E{episode:00} - {Absolute Episode} - {Episode Title} [{Quality Title}]".to_string() -} -fn default_season_folder_format() -> String { - "Season {season:00}".to_string() -} -fn default_movie_format() -> String { - "{Movie Title} ({Release Year}) [{Quality Title}]".to_string() -} -fn default_movie_folder_format() -> String { - "{Movie Title} ({Release Year})".to_string() -} - -impl Default for AppConfig { - fn default() -> Self { - Self { - general: GeneralConfig::default(), - database: DatabaseConfig::default(), - auth: AuthConfig::default(), - torrent: TorrentConfig::default(), - usenet: UsenetConfig::default(), - indexarr: IndexarrConfig::default(), - naming: NamingConfig::default(), - streaming: StreamingConfig::default(), - bootstrap: BootstrapConfig::default(), - } - } -} - -impl Default for GeneralConfig { - fn default() -> Self { - Self { - instance_name: default_instance_name(), - bind_addr: default_bind_addr(), - port: default_port(), - data_dir: default_data_dir(), - log_level: default_log_level(), - } - } -} - -impl Default for DatabaseConfig { - fn default() -> Self { - Self { - url: "postgresql://stackarr:stackarr@localhost:5432/stackarr".to_string(), - max_connections: default_max_connections(), - } - } -} - -impl Default for AuthConfig { - fn default() -> Self { - Self { - method: default_auth_method(), - api_key: None, - } - } -} - -impl AppConfig { - pub fn load(path: &Path) -> crate::Result { - if !path.exists() { - return Err(crate::Error::Config(format!( - "config file not found: {} — use generate_default() to create one", - path.display() - ))); - } - let content = std::fs::read_to_string(path).map_err(|e| { - crate::Error::Config(format!("failed to read config file {}: {e}", path.display())) - })?; - toml::from_str(&content) - .map_err(|e| crate::Error::Config(format!("failed to parse config: {e}"))) - } - - pub fn generate_default(path: &Path) -> crate::Result { - let config = Self::default(); - let content = toml::to_string_pretty(&config) - .map_err(|e| crate::Error::Config(format!("failed to serialize default config: {e}")))?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, content)?; - Ok(config) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_config_default_values() { - let cfg = AppConfig::default(); - assert_eq!(cfg.general.port, 8989); - assert_eq!(cfg.general.bind_addr, "0.0.0.0"); - assert_eq!(cfg.general.log_level, "info"); - assert_eq!(cfg.general.instance_name, "StackArr"); - assert_eq!(cfg.general.data_dir, PathBuf::from("/config")); - assert_eq!(cfg.database.max_connections, 20); - assert_eq!(cfg.auth.method, "forms"); - assert!(cfg.auth.api_key.is_none()); - } - - #[test] - fn test_config_roundtrip_toml() { - let original = AppConfig::default(); - let toml_str = toml::to_string_pretty(&original).expect("serialize"); - let parsed: AppConfig = toml::from_str(&toml_str).expect("deserialize"); - assert_eq!(parsed.general.port, original.general.port); - assert_eq!(parsed.general.bind_addr, original.general.bind_addr); - assert_eq!(parsed.database.url, original.database.url); - assert_eq!(parsed.torrent.listen_port, original.torrent.listen_port); - assert_eq!(parsed.usenet.max_active_downloads, original.usenet.max_active_downloads); - } - - #[test] - fn test_config_load_missing_file() { - let result = AppConfig::load(Path::new("/nonexistent/path/config.toml")); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("config file not found")); - } - - #[test] - fn test_config_partial_toml_uses_defaults() { - let toml_str = r#" -[general] -port = 9090 - -[database] -url = "postgresql://test:test@localhost:5432/test" - -[auth] -method = "none" -"#; - let cfg: AppConfig = toml::from_str(toml_str).expect("parse partial config"); - assert_eq!(cfg.general.port, 9090); - // Fields not in the TOML should get defaults - assert_eq!(cfg.general.bind_addr, "0.0.0.0"); - assert_eq!(cfg.general.instance_name, "StackArr"); - assert!(!cfg.torrent.enabled); - assert!(!cfg.usenet.enabled); - } - - #[test] - fn test_enabled_modules_default_all_false() { - let modules = EnabledModules::default(); - assert!(!modules.tv_management); - assert!(!modules.movie_management); - assert!(!modules.torrent_embedded); - assert!(!modules.usenet_embedded); - assert!(!modules.torrent_external); - assert!(!modules.usenet_external); - assert!(!modules.indexarr_sidecar); - assert!(!modules.external_indexers); - assert!(!modules.plex_integration); - assert!(!modules.notifications); - assert!(!modules.streaming); - assert!(!modules.remote_access); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/db.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/db.rs deleted file mode 100644 index bbeb733e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/db.rs +++ /dev/null @@ -1,1407 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::Serialize; -use sqlx::postgres::PgPoolOptions; -use sqlx::PgPool; -use uuid::Uuid; - -use crate::config::{DatabaseConfig, EnabledModules}; -use crate::models::user::{ContinueWatchingItem, Invite, MediaRequest, PushSubscription, User, UserDevice, UserNotification, UserRating, UserSession, UserWatchlistItem, WatchProgress}; - -#[derive(Clone)] -pub struct Database { - pool: PgPool, -} - -impl Database { - pub async fn connect(config: &DatabaseConfig) -> crate::Result { - let pool = PgPoolOptions::new() - .max_connections(config.max_connections) - .connect(&config.url) - .await?; - Ok(Self { pool }) - } - - pub fn pool(&self) -> &PgPool { - &self.pool - } - - /// Create a Database wrapper from an existing pool (useful in tests). - #[cfg(any(test, feature = "testing"))] - pub fn from_pool(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn run_migrations(&self) -> crate::Result<()> { - sqlx::migrate!("../../migrations") - .run(&self.pool) - .await - .map_err(|e| crate::Error::Config(format!("migration failed: {e}")))?; - Ok(()) - } - - pub async fn is_first_boot(&self) -> crate::Result { - let result: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM enabled_modules WHERE enabled = true") - .fetch_one(&self.pool) - .await?; - Ok(result.0 == 0) - } - - pub async fn load_enabled_modules(&self) -> crate::Result { - let rows: Vec<(String, bool)> = - sqlx::query_as("SELECT module, enabled FROM enabled_modules") - .fetch_all(&self.pool) - .await?; - - let mut modules = EnabledModules::default(); - for (module, enabled) in rows { - match module.as_str() { - "tv_management" => modules.tv_management = enabled, - "movie_management" => modules.movie_management = enabled, - "torrent_embedded" => modules.torrent_embedded = enabled, - "usenet_embedded" => modules.usenet_embedded = enabled, - "torrent_external" => modules.torrent_external = enabled, - "usenet_external" => modules.usenet_external = enabled, - "indexarr_sidecar" => modules.indexarr_sidecar = enabled, - "external_indexers" => modules.external_indexers = enabled, - "plex_integration" => modules.plex_integration = enabled, - "notifications" => modules.notifications = enabled, - "streaming" => modules.streaming = enabled, - "remote_access" => modules.remote_access = enabled, - _ => {} - } - } - Ok(modules) - } - - pub async fn save_enabled_modules(&self, modules: &EnabledModules) -> crate::Result<()> { - let module_list = [ - ("tv_management", modules.tv_management), - ("movie_management", modules.movie_management), - ("torrent_embedded", modules.torrent_embedded), - ("usenet_embedded", modules.usenet_embedded), - ("torrent_external", modules.torrent_external), - ("usenet_external", modules.usenet_external), - ("indexarr_sidecar", modules.indexarr_sidecar), - ("external_indexers", modules.external_indexers), - ("plex_integration", modules.plex_integration), - ("notifications", modules.notifications), - ("streaming", modules.streaming), - ("remote_access", modules.remote_access), - ]; - - for (name, enabled) in module_list { - sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) - ON CONFLICT (module) DO UPDATE SET enabled = $2", - ) - .bind(name) - .bind(enabled) - .execute(&self.pool) - .await?; - } - Ok(()) - } - - // ── Server identity ───────────────────────────────────────────────── - - /// Load or generate the stable server identity UUID. - pub async fn ensure_server_id(&self) -> crate::Result { - let existing: Option = sqlx::query_scalar( - "SELECT value FROM app_config WHERE key = 'server_id'", - ) - .fetch_optional(&self.pool) - .await?; - - match existing { - Some(val) => { - let id_str = val - .as_str() - .ok_or_else(|| crate::Error::Config("server_id is not a string".into()))?; - Uuid::parse_str(id_str) - .map_err(|e| crate::Error::Config(format!("invalid server_id: {e}"))) - } - None => { - let new_id = Uuid::new_v4(); - sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('server_id', $1)", - ) - .bind(serde_json::Value::String(new_id.to_string())) - .execute(&self.pool) - .await?; - Ok(new_id) - } - } - } - - // ── Remote clients ────────────────────────────────────────────────── - - pub async fn create_remote_client(&self, client_token: Uuid) -> crate::Result { - let row: (i32,) = sqlx::query_as( - "INSERT INTO remote_clients (client_token) VALUES ($1) RETURNING id", - ) - .bind(client_token) - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } - - pub async fn set_remote_client_name( - &self, - client_token: Uuid, - name: &str, - ) -> crate::Result { - let result = sqlx::query( - "UPDATE remote_clients SET client_name = $1, last_seen = NOW() \ - WHERE client_token = $2 AND revoked = false", - ) - .bind(name) - .bind(client_token) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn validate_remote_client(&self, client_token: Uuid) -> crate::Result { - let row: Option<(bool,)> = sqlx::query_as( - "SELECT revoked FROM remote_clients WHERE client_token = $1", - ) - .bind(client_token) - .fetch_optional(&self.pool) - .await?; - match row { - Some((revoked,)) => Ok(!revoked), - None => Ok(false), - } - } - - pub async fn touch_remote_client(&self, client_token: Uuid) -> crate::Result<()> { - sqlx::query( - "UPDATE remote_clients SET last_seen = NOW() WHERE client_token = $1", - ) - .bind(client_token) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn list_remote_clients(&self) -> crate::Result> { - let rows = sqlx::query_as::<_, RemoteClient>( - "SELECT id, client_token, client_name, created_at, last_seen, revoked \ - FROM remote_clients ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn revoke_remote_client(&self, id: i32) -> crate::Result { - let result = sqlx::query( - "UPDATE remote_clients SET revoked = true WHERE id = $1", - ) - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn delete_remote_client(&self, id: i32) -> crate::Result { - let result = sqlx::query("DELETE FROM remote_clients WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - // ── Users ──────────────────────────────────────────────────────────── - - pub async fn create_user( - &self, - username: &str, - display_name: &str, - password_hash: &str, - role: &str, - ) -> crate::Result { - let user = sqlx::query_as::<_, User>( - "INSERT INTO users (username, display_name, password_hash, role) \ - VALUES ($1, $2, $3, $4) RETURNING *", - ) - .bind(username) - .bind(display_name) - .bind(password_hash) - .bind(role) - .fetch_one(&self.pool) - .await?; - Ok(user) - } - - pub async fn get_user_by_id(&self, id: i64) -> crate::Result> { - let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn get_user_by_username(&self, username: &str) -> crate::Result> { - let user = - sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") - .bind(username) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn list_users(&self) -> crate::Result> { - let users = - sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY created_at DESC") - .fetch_all(&self.pool) - .await?; - Ok(users) - } - - pub async fn update_user( - &self, - id: i64, - display_name: &str, - role: &str, - enabled: bool, - avatar_url: Option<&str>, - ) -> crate::Result> { - let user = sqlx::query_as::<_, User>( - "UPDATE users SET display_name = $1, role = $2, enabled = $3, avatar_url = $4, \ - updated_at = NOW() WHERE id = $5 RETURNING *", - ) - .bind(display_name) - .bind(role) - .bind(enabled) - .bind(avatar_url) - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn update_user_password( - &self, - id: i64, - password_hash: &str, - ) -> crate::Result { - let result = sqlx::query( - "UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2", - ) - .bind(password_hash) - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn delete_user(&self, id: i64) -> crate::Result { - let result = sqlx::query("DELETE FROM users WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn count_users(&self) -> crate::Result { - let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } - - // ── Sessions ───────────────────────────────────────────────────────── - - pub async fn create_session( - &self, - user_id: i64, - token_hash: &str, - user_agent: Option<&str>, - ip_address: Option<&str>, - expires_at: DateTime, - ) -> crate::Result { - let session = sqlx::query_as::<_, UserSession>( - "INSERT INTO user_sessions (user_id, token_hash, user_agent, ip_address, expires_at) \ - VALUES ($1, $2, $3, $4::INET, $5) RETURNING id, user_id, token_hash, user_agent, \ - ip_address::TEXT, created_at, expires_at, last_active", - ) - .bind(user_id) - .bind(token_hash) - .bind(user_agent) - .bind(ip_address) - .bind(expires_at) - .fetch_one(&self.pool) - .await?; - Ok(session) - } - - /// Validate a session token hash and return the associated user if valid. - /// Checks that the session is not expired and the user is enabled. - pub async fn validate_session(&self, token_hash: &str) -> crate::Result> { - let user = sqlx::query_as::<_, User>( - "SELECT u.* FROM users u \ - INNER JOIN user_sessions s ON s.user_id = u.id \ - WHERE s.token_hash = $1 AND s.expires_at > NOW() AND u.enabled = true", - ) - .bind(token_hash) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn touch_session(&self, token_hash: &str) -> crate::Result<()> { - sqlx::query( - "UPDATE user_sessions SET last_active = NOW() WHERE token_hash = $1", - ) - .bind(token_hash) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn delete_session(&self, token_hash: &str) -> crate::Result { - let result = - sqlx::query("DELETE FROM user_sessions WHERE token_hash = $1") - .bind(token_hash) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn delete_all_sessions(&self, user_id: i64) -> crate::Result { - let result = - sqlx::query("DELETE FROM user_sessions WHERE user_id = $1") - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - pub async fn list_sessions(&self, user_id: i64) -> crate::Result> { - let sessions = sqlx::query_as::<_, UserSession>( - "SELECT id, user_id, token_hash, user_agent, ip_address::TEXT, \ - created_at, expires_at, last_active \ - FROM user_sessions WHERE user_id = $1 ORDER BY last_active DESC", - ) - .bind(user_id) - .fetch_all(&self.pool) - .await?; - Ok(sessions) - } - - pub async fn cleanup_expired_sessions(&self) -> crate::Result { - let result = - sqlx::query("DELETE FROM user_sessions WHERE expires_at <= NOW()") - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - // ── User devices ───────────────────────────────────────────────────── - - pub async fn create_user_device( - &self, - user_id: i64, - device_token: Uuid, - device_name: Option<&str>, - device_type: Option<&str>, - ) -> crate::Result { - let device = sqlx::query_as::<_, UserDevice>( - "INSERT INTO user_devices (user_id, device_token, device_name, device_type) \ - VALUES ($1, $2, $3, $4) RETURNING *", - ) - .bind(user_id) - .bind(device_token) - .bind(device_name) - .bind(device_type) - .fetch_one(&self.pool) - .await?; - Ok(device) - } - - /// Validate a device token and return the associated user if valid. - pub async fn validate_user_device(&self, device_token: Uuid) -> crate::Result> { - let user = sqlx::query_as::<_, User>( - "SELECT u.* FROM users u \ - INNER JOIN user_devices d ON d.user_id = u.id \ - WHERE d.device_token = $1 AND d.revoked = false AND u.enabled = true", - ) - .bind(device_token) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn touch_user_device(&self, device_token: Uuid) -> crate::Result<()> { - sqlx::query( - "UPDATE user_devices SET last_seen = NOW() WHERE device_token = $1", - ) - .bind(device_token) - .execute(&self.pool) - .await?; - Ok(()) - } - - pub async fn list_user_devices(&self, user_id: i64) -> crate::Result> { - let devices = sqlx::query_as::<_, UserDevice>( - "SELECT * FROM user_devices WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(user_id) - .fetch_all(&self.pool) - .await?; - Ok(devices) - } - - pub async fn revoke_user_device(&self, id: i32) -> crate::Result { - let result = sqlx::query( - "UPDATE user_devices SET revoked = true WHERE id = $1", - ) - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn delete_user_device(&self, id: i32) -> crate::Result { - let result = sqlx::query("DELETE FROM user_devices WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn link_device_to_user( - &self, - device_token: Uuid, - user_id: i64, - ) -> crate::Result { - let result = sqlx::query( - "UPDATE user_devices SET user_id = $1 WHERE device_token = $2", - ) - .bind(user_id) - .bind(device_token) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - // ── Invites ────────────────────────────────────────────────────────── - - pub async fn create_invite( - &self, - code: &str, - created_by: i64, - role: &str, - expires_at: Option>, - ) -> crate::Result { - let invite = sqlx::query_as::<_, Invite>( - "INSERT INTO invites (code, created_by, role, expires_at) \ - VALUES ($1, $2, $3, $4) RETURNING *", - ) - .bind(code) - .bind(created_by) - .bind(role) - .bind(expires_at) - .fetch_one(&self.pool) - .await?; - Ok(invite) - } - - /// Validate an invite code. Returns the invite if it's unclaimed and not expired. - pub async fn validate_invite(&self, code: &str) -> crate::Result> { - let invite = sqlx::query_as::<_, Invite>( - "SELECT * FROM invites WHERE code = $1 AND claimed_by IS NULL \ - AND (expires_at IS NULL OR expires_at > NOW())", - ) - .bind(code) - .fetch_optional(&self.pool) - .await?; - Ok(invite) - } - - pub async fn claim_invite(&self, code: &str, user_id: i64) -> crate::Result { - let result = sqlx::query( - "UPDATE invites SET claimed_by = $1 WHERE code = $2 AND claimed_by IS NULL", - ) - .bind(user_id) - .bind(code) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn list_invites(&self) -> crate::Result> { - let invites = sqlx::query_as::<_, Invite>( - "SELECT * FROM invites ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await?; - Ok(invites) - } - - pub async fn delete_invite(&self, id: i32) -> crate::Result { - let result = sqlx::query("DELETE FROM invites WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - // ── Migration helpers ──────────────────────────────────────────────── - - // ── Watch progress ───────────────────────────────────────────────── - - pub async fn upsert_watch_progress( - &self, - user_id: i64, - media_file_id: i64, - media_type: &str, - media_id: i64, - episode_id: Option, - position_secs: f32, - duration_secs: f32, - completed: bool, - ) -> crate::Result { - let row = sqlx::query_as::<_, WatchProgress>( - "INSERT INTO watch_progress (user_id, media_file_id, media_type, media_id, episode_id, \ - position_secs, duration_secs, completed, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW()) \ - ON CONFLICT (user_id, media_file_id) DO UPDATE SET \ - position_secs = $6, duration_secs = $7, completed = $8, updated_at = NOW() \ - RETURNING *", - ) - .bind(user_id) - .bind(media_file_id) - .bind(media_type) - .bind(media_id) - .bind(episode_id) - .bind(position_secs) - .bind(duration_secs) - .bind(completed) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn get_watch_progress( - &self, - user_id: i64, - media_file_id: i64, - ) -> crate::Result> { - let row = sqlx::query_as::<_, WatchProgress>( - "SELECT * FROM watch_progress WHERE user_id = $1 AND media_file_id = $2", - ) - .bind(user_id) - .bind(media_file_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn get_continue_watching( - &self, - user_id: i64, - limit: i64, - ) -> crate::Result> { - let rows = sqlx::query_as::<_, WatchProgress>( - "SELECT * FROM watch_progress \ - WHERE user_id = $1 AND completed = false AND position_secs > 0 \ - ORDER BY updated_at DESC LIMIT $2", - ) - .bind(user_id) - .bind(limit) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get_series_progress( - &self, - user_id: i64, - series_id: i64, - ) -> crate::Result> { - let rows = sqlx::query_as::<_, WatchProgress>( - "SELECT * FROM watch_progress \ - WHERE user_id = $1 AND media_type = 'series' AND media_id = $2 \ - ORDER BY updated_at DESC", - ) - .bind(user_id) - .bind(series_id) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get_movie_progress( - &self, - user_id: i64, - movie_id: i64, - ) -> crate::Result> { - let row = sqlx::query_as::<_, WatchProgress>( - "SELECT * FROM watch_progress \ - WHERE user_id = $1 AND media_type = 'movie' AND media_id = $2 \ - ORDER BY updated_at DESC LIMIT 1", - ) - .bind(user_id) - .bind(movie_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn delete_watch_progress( - &self, - user_id: i64, - media_file_id: i64, - ) -> crate::Result { - let result = sqlx::query( - "DELETE FROM watch_progress WHERE user_id = $1 AND media_file_id = $2", - ) - .bind(user_id) - .bind(media_file_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn mark_series_watched( - &self, - user_id: i64, - series_id: i64, - ) -> crate::Result { - let result = sqlx::query( - "UPDATE watch_progress SET completed = true, updated_at = NOW() \ - WHERE user_id = $1 AND media_type = 'series' AND media_id = $2", - ) - .bind(user_id) - .bind(series_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - /// Resolve a media_file_id to its media_type, media_id, and episode_id. - pub async fn resolve_media_file( - &self, - media_file_id: i64, - ) -> crate::Result)>> { - let row: Option<(String, i64, Option)> = sqlx::query_as( - "SELECT \ - CASE WHEN ef.id IS NOT NULL THEN 'series' ELSE 'movie' END AS media_type, \ - COALESCE(e.series_id, mf_movie.movie_id, 0) AS media_id, \ - ef.episode_id \ - FROM media_files mf \ - LEFT JOIN episode_files ef ON ef.media_file_id = mf.id \ - LEFT JOIN episodes e ON e.id = ef.episode_id \ - LEFT JOIN ( \ - SELECT m.id AS movie_id, m.movie_file_id FROM movies m WHERE m.movie_file_id IS NOT NULL \ - ) mf_movie ON mf_movie.movie_file_id = mf.id \ - WHERE mf.id = $1 \ - LIMIT 1", - ) - .bind(media_file_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - /// Get enriched continue-watching items with media metadata via JOINs. - pub async fn get_continue_watching_enriched( - &self, - user_id: i64, - limit: i64, - ) -> crate::Result> { - // Fetch base progress rows plus joined metadata. - // LEFT JOIN to series, movies, and episodes to enrich. - let rows: Vec = sqlx::query_as::<_, ContinueWatchingRow>( - "SELECT wp.id, wp.user_id, wp.media_file_id, wp.media_type, wp.media_id, \ - wp.episode_id, wp.position_secs, wp.duration_secs, wp.completed, wp.updated_at, \ - COALESCE(s.title, m.title) AS title, \ - s.images AS series_images, \ - m.images AS movie_images, \ - e.title AS episode_title, \ - e.season_number, \ - e.episode_number, \ - COALESCE(s.year, m.year) AS year \ - FROM watch_progress wp \ - LEFT JOIN series s ON wp.media_type = 'series' AND s.id = wp.media_id \ - LEFT JOIN movies m ON wp.media_type = 'movie' AND m.id = wp.media_id \ - LEFT JOIN episodes e ON e.id = wp.episode_id \ - WHERE wp.user_id = $1 AND wp.completed = false AND wp.position_secs > 0 \ - ORDER BY wp.updated_at DESC LIMIT $2", - ) - .bind(user_id) - .bind(limit) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(|r| { - let images = r.series_images.as_ref().or(r.movie_images.as_ref()); - let poster_url = extract_image_url_from_json(images, "poster"); - let backdrop_url = extract_image_url_from_json(images, "fanart"); - ContinueWatchingItem { - id: r.id, - user_id: r.user_id, - media_file_id: r.media_file_id, - media_type: r.media_type, - media_id: r.media_id, - episode_id: r.episode_id, - position_secs: r.position_secs, - duration_secs: r.duration_secs, - completed: r.completed, - updated_at: r.updated_at, - title: r.title, - poster_url, - backdrop_url, - episode_title: r.episode_title, - season_number: r.season_number, - episode_number: r.episode_number, - year: r.year, - } - }) - .collect(); - Ok(rows) - } - - // ── Media requests ───────────────────────────────────────────── - - pub async fn create_media_request( - &self, - user_id: i64, - media_type: &str, - tmdb_id: i64, - title: &str, - year: Option, - poster_url: Option<&str>, - overview: Option<&str>, - ) -> crate::Result { - let row = sqlx::query_as::<_, MediaRequest>( - "INSERT INTO media_requests (user_id, media_type, tmdb_id, title, year, poster_url, overview) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *", - ) - .bind(user_id) - .bind(media_type) - .bind(tmdb_id) - .bind(title) - .bind(year) - .bind(poster_url) - .bind(overview) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn get_media_request(&self, id: i64) -> crate::Result> { - let row = sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE id = $1", - ) - .bind(id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn list_media_requests( - &self, - status: Option<&str>, - user_id: Option, - ) -> crate::Result> { - let rows = match (status, user_id) { - (Some(s), Some(uid)) => { - sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE status = $1 AND user_id = $2 ORDER BY created_at DESC", - ) - .bind(s) - .bind(uid) - .fetch_all(&self.pool) - .await? - } - (Some(s), None) => { - sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE status = $1 ORDER BY created_at DESC", - ) - .bind(s) - .fetch_all(&self.pool) - .await? - } - (None, Some(uid)) => { - sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(uid) - .fetch_all(&self.pool) - .await? - } - (None, None) => { - sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await? - } - }; - Ok(rows) - } - - pub async fn update_request_status( - &self, - id: i64, - status: &str, - approved_by: Option, - admin_note: Option<&str>, - ) -> crate::Result { - let result = sqlx::query( - "UPDATE media_requests SET status = $1, approved_by = $2, admin_note = $3, \ - updated_at = NOW() WHERE id = $4", - ) - .bind(status) - .bind(approved_by) - .bind(admin_note) - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn delete_media_request(&self, id: i64) -> crate::Result { - let result = sqlx::query("DELETE FROM media_requests WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn check_request_exists( - &self, - tmdb_id: i64, - media_type: &str, - ) -> crate::Result> { - let row = sqlx::query_as::<_, MediaRequest>( - "SELECT * FROM media_requests WHERE tmdb_id = $1 AND media_type = $2", - ) - .bind(tmdb_id) - .bind(media_type) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn mark_request_available( - &self, - tmdb_id: i64, - media_type: &str, - ) -> crate::Result { - let result = sqlx::query( - "UPDATE media_requests SET status = 'available', updated_at = NOW() \ - WHERE tmdb_id = $1 AND media_type = $2 AND status != 'available'", - ) - .bind(tmdb_id) - .bind(media_type) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn count_pending_requests(&self) -> crate::Result { - let row: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM media_requests WHERE status = 'pending'") - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } - - // ── Watchlist ───────────────────────────────────────────────── - - pub async fn add_to_watchlist( - &self, - user_id: i64, - media_type: &str, - media_id: i64, - tmdb_id: i64, - ) -> crate::Result { - let row = sqlx::query_as::<_, UserWatchlistItem>( - "INSERT INTO user_watchlist (user_id, media_type, media_id, tmdb_id) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id, media_type, media_id) DO UPDATE SET added_at = NOW() \ - RETURNING *", - ) - .bind(user_id) - .bind(media_type) - .bind(media_id) - .bind(tmdb_id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn remove_from_watchlist( - &self, - user_id: i64, - media_type: &str, - media_id: i64, - ) -> crate::Result { - let result = sqlx::query( - "DELETE FROM user_watchlist WHERE user_id = $1 AND media_type = $2 AND media_id = $3", - ) - .bind(user_id) - .bind(media_type) - .bind(media_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn get_watchlist( - &self, - user_id: i64, - media_type: Option<&str>, - ) -> crate::Result> { - let rows = match media_type { - Some(mt) => { - sqlx::query_as::<_, UserWatchlistItem>( - "SELECT * FROM user_watchlist WHERE user_id = $1 AND media_type = $2 \ - ORDER BY added_at DESC", - ) - .bind(user_id) - .bind(mt) - .fetch_all(&self.pool) - .await? - } - None => { - sqlx::query_as::<_, UserWatchlistItem>( - "SELECT * FROM user_watchlist WHERE user_id = $1 ORDER BY added_at DESC", - ) - .bind(user_id) - .fetch_all(&self.pool) - .await? - } - }; - Ok(rows) - } - - pub async fn is_on_watchlist( - &self, - user_id: i64, - media_type: &str, - media_id: i64, - ) -> crate::Result { - let row: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM user_watchlist \ - WHERE user_id = $1 AND media_type = $2 AND media_id = $3", - ) - .bind(user_id) - .bind(media_type) - .bind(media_id) - .fetch_one(&self.pool) - .await?; - Ok(row.0 > 0) - } - - // ── Ratings ───────────────────────────────────────────────────── - - pub async fn set_rating( - &self, - user_id: i64, - media_type: &str, - media_id: i64, - rating: i16, - ) -> crate::Result { - let row = sqlx::query_as::<_, UserRating>( - "INSERT INTO user_ratings (user_id, media_type, media_id, rating) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id, media_type, media_id) DO UPDATE SET \ - rating = $4, updated_at = NOW() \ - RETURNING *", - ) - .bind(user_id) - .bind(media_type) - .bind(media_id) - .bind(rating) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn get_rating( - &self, - user_id: i64, - media_type: &str, - media_id: i64, - ) -> crate::Result> { - let row = sqlx::query_as::<_, UserRating>( - "SELECT * FROM user_ratings WHERE user_id = $1 AND media_type = $2 AND media_id = $3", - ) - .bind(user_id) - .bind(media_type) - .bind(media_id) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - - pub async fn delete_rating( - &self, - user_id: i64, - media_type: &str, - media_id: i64, - ) -> crate::Result { - let result = sqlx::query( - "DELETE FROM user_ratings WHERE user_id = $1 AND media_type = $2 AND media_id = $3", - ) - .bind(user_id) - .bind(media_type) - .bind(media_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn get_user_ratings( - &self, - user_id: i64, - media_type: Option<&str>, - ) -> crate::Result> { - let rows = match media_type { - Some(mt) => { - sqlx::query_as::<_, UserRating>( - "SELECT * FROM user_ratings WHERE user_id = $1 AND media_type = $2 \ - ORDER BY updated_at DESC", - ) - .bind(user_id) - .bind(mt) - .fetch_all(&self.pool) - .await? - } - None => { - sqlx::query_as::<_, UserRating>( - "SELECT * FROM user_ratings WHERE user_id = $1 ORDER BY updated_at DESC", - ) - .bind(user_id) - .fetch_all(&self.pool) - .await? - } - }; - Ok(rows) - } - - pub async fn get_average_rating( - &self, - media_type: &str, - media_id: i64, - ) -> crate::Result<(f64, i64)> { - let row: (Option, i64) = sqlx::query_as( - "SELECT AVG(rating::DOUBLE PRECISION), COUNT(*) FROM user_ratings \ - WHERE media_type = $1 AND media_id = $2", - ) - .bind(media_type) - .bind(media_id) - .fetch_one(&self.pool) - .await?; - Ok((row.0.unwrap_or(0.0), row.1)) - } - - // ── Notifications ───────────────────────────────────────────── - - pub async fn create_notification( - &self, - user_id: i64, - notification_type: &str, - title: &str, - body: Option<&str>, - data: Option, - ) -> crate::Result { - let row = sqlx::query_as::<_, UserNotification>( - "INSERT INTO user_notifications (user_id, notification_type, title, body, data) \ - VALUES ($1, $2, $3, $4, $5) RETURNING *", - ) - .bind(user_id) - .bind(notification_type) - .bind(title) - .bind(body) - .bind(data) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn create_notification_for_all_users( - &self, - notification_type: &str, - title: &str, - body: Option<&str>, - data: Option, - ) -> crate::Result { - let result = sqlx::query( - "INSERT INTO user_notifications (user_id, notification_type, title, body, data) \ - SELECT id, $1, $2, $3, $4 FROM users WHERE enabled = true", - ) - .bind(notification_type) - .bind(title) - .bind(body) - .bind(data) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - pub async fn list_notifications( - &self, - user_id: i64, - unread_only: bool, - limit: i64, - offset: i64, - ) -> crate::Result> { - let rows = if unread_only { - sqlx::query_as::<_, UserNotification>( - "SELECT * FROM user_notifications \ - WHERE user_id = $1 AND read = false \ - ORDER BY created_at DESC LIMIT $2 OFFSET $3", - ) - .bind(user_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await? - } else { - sqlx::query_as::<_, UserNotification>( - "SELECT * FROM user_notifications \ - WHERE user_id = $1 \ - ORDER BY created_at DESC LIMIT $2 OFFSET $3", - ) - .bind(user_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await? - }; - Ok(rows) - } - - pub async fn unread_notification_count(&self, user_id: i64) -> crate::Result { - let row: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM user_notifications WHERE user_id = $1 AND read = false", - ) - .bind(user_id) - .fetch_one(&self.pool) - .await?; - Ok(row.0) - } - - pub async fn mark_notification_read(&self, id: i64, user_id: i64) -> crate::Result { - let result = sqlx::query( - "UPDATE user_notifications SET read = true WHERE id = $1 AND user_id = $2", - ) - .bind(id) - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - pub async fn mark_all_notifications_read(&self, user_id: i64) -> crate::Result { - let result = sqlx::query( - "UPDATE user_notifications SET read = true WHERE user_id = $1 AND read = false", - ) - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - pub async fn delete_old_notifications(&self, days: i32) -> crate::Result { - let result = sqlx::query( - "DELETE FROM user_notifications WHERE created_at < NOW() - make_interval(days => $1)", - ) - .bind(days) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - // ── Push subscriptions ──────────────────────────────────────── - - pub async fn save_push_subscription( - &self, - user_id: i64, - endpoint: &str, - p256dh: &str, - auth: &str, - user_agent: Option<&str>, - ) -> crate::Result { - let row = sqlx::query_as::<_, PushSubscription>( - "INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent) \ - VALUES ($1, $2, $3, $4, $5) \ - ON CONFLICT (endpoint) DO UPDATE SET \ - user_id = $1, p256dh = $3, auth = $4, user_agent = $5 \ - RETURNING *", - ) - .bind(user_id) - .bind(endpoint) - .bind(p256dh) - .bind(auth) - .bind(user_agent) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn get_push_subscriptions( - &self, - user_id: i64, - ) -> crate::Result> { - let rows = sqlx::query_as::<_, PushSubscription>( - "SELECT * FROM push_subscriptions WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(user_id) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn delete_push_subscription(&self, endpoint: &str, user_id: i64) -> crate::Result { - let result = sqlx::query( - "DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2", - ) - .bind(endpoint) - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - /// Migrate existing remote_clients to user_devices for a given user. - pub async fn migrate_remote_clients_to_user_devices( - &self, - user_id: i64, - ) -> crate::Result { - let result = sqlx::query( - "INSERT INTO user_devices (user_id, device_token, device_name, created_at, last_seen, revoked) \ - SELECT $1, client_token, client_name, created_at, last_seen, revoked \ - FROM remote_clients \ - ON CONFLICT (device_token) DO NOTHING", - ) - .bind(user_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } -} - -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -pub struct RemoteClient { - pub id: i32, - pub client_token: Uuid, - pub client_name: Option, - pub created_at: DateTime, - pub last_seen: Option>, - pub revoked: bool, -} - -/// Internal row type for the enriched continue-watching query. -#[derive(Debug, Clone, sqlx::FromRow)] -struct ContinueWatchingRow { - id: i64, - user_id: i64, - media_file_id: i64, - media_type: String, - media_id: i64, - episode_id: Option, - position_secs: f32, - duration_secs: f32, - completed: bool, - updated_at: DateTime, - title: Option, - series_images: Option, - movie_images: Option, - episode_title: Option, - season_number: Option, - episode_number: Option, - year: Option, -} - -/// Extract a proxied image URL from a JSONB images value by cover type. -fn extract_image_url_from_json( - images: Option<&serde_json::Value>, - cover_type: &str, -) -> Option { - images?.as_array()?.iter().find_map(|img| { - if img.get("coverType")?.as_str()? == cover_type { - img.get("remoteUrl") - .or_else(|| img.get("url")) - .and_then(|v| v.as_str()) - .map(|url| format!("/api/v1/images/{url}")) - } else { - None - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_helpers::TestDb; - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_connect_and_migrate() { - let db = TestDb::new().await; - // If we get here, connect + migrations succeeded - // Verify a table exists by running a simple query - let _: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM enabled_modules") - .fetch_one(&db.pool) - .await - .expect("enabled_modules table should exist"); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_is_first_boot_true() { - let db = TestDb::new().await; - let database = Database { pool: db.pool.clone() }; - let first = database.is_first_boot().await.expect("is_first_boot"); - assert!(first, "fresh DB should be first boot"); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_enabled_modules_round_trip() { - let db = TestDb::new().await; - let database = Database { pool: db.pool.clone() }; - - let modules = EnabledModules { - tv_management: true, - movie_management: true, - torrent_embedded: false, - usenet_embedded: true, - torrent_external: false, - usenet_external: false, - indexarr_sidecar: true, - external_indexers: false, - plex_integration: true, - notifications: false, - streaming: false, - remote_access: false, - }; - database.save_enabled_modules(&modules).await.expect("save"); - - let loaded = database.load_enabled_modules().await.expect("load"); - assert_eq!(loaded.tv_management, true); - assert_eq!(loaded.movie_management, true); - assert_eq!(loaded.torrent_embedded, false); - assert_eq!(loaded.usenet_embedded, true); - assert_eq!(loaded.indexarr_sidecar, true); - assert_eq!(loaded.plex_integration, true); - assert_eq!(loaded.notifications, false); - - // After saving modules, no longer first boot - let first = database.is_first_boot().await.expect("is_first_boot"); - assert!(!first); - - db.cleanup().await; - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/error.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/error.rs deleted file mode 100644 index 4c076b41..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/error.rs +++ /dev/null @@ -1,71 +0,0 @@ -use thiserror::Error; - -pub type Result = std::result::Result; - -#[derive(Debug, Error)] -pub enum Error { - #[error("not found: {0}")] - NotFound(String), - - #[error("already exists: {0}")] - AlreadyExists(String), - - #[error("validation error: {0}")] - Validation(String), - - #[error("configuration error: {0}")] - Config(String), - - #[error("database error: {0}")] - Database(#[from] sqlx::Error), - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - #[error("serialization error: {0}")] - Serialization(#[from] serde_json::Error), - - #[error("http error: {0}")] - Http(String), - - #[error("download client error: {0}")] - DownloadClient(String), - - #[error("indexer error: {0}")] - Indexer(String), - - #[error("parse error: {0}")] - Parse(String), - - #[error("{0}")] - Other(#[from] anyhow::Error), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_display_not_found() { - let err = Error::NotFound("series 42".into()); - assert_eq!(err.to_string(), "not found: series 42"); - } - - #[test] - fn test_error_display_validation() { - let err = Error::Validation("title is required".into()); - assert_eq!(err.to_string(), "validation error: title is required"); - } - - #[test] - fn test_error_display_config() { - let err = Error::Config("bad toml".into()); - assert_eq!(err.to_string(), "configuration error: bad toml"); - } - - #[test] - fn test_error_display_download_client() { - let err = Error::DownloadClient("connection refused".into()); - assert_eq!(err.to_string(), "download client error: connection refused"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/lib.rs deleted file mode 100644 index 1a872f4c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod auth; -pub mod config; -pub mod db; -pub mod error; -pub mod models; -pub mod notifications; - -#[cfg(any(test, feature = "testing"))] -pub mod test_helpers; - -pub use config::AppConfig; -pub use db::Database; -pub use error::{Error, Result}; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models.rs deleted file mode 100644 index 828c1f1c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod discover; -pub mod download; -pub mod history; -pub mod media; -pub mod quality; -pub mod user; - -pub use discover::*; -pub use download::*; -pub use history::*; -pub use media::*; -pub use quality::*; -pub use user::*; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/discover.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/discover.rs deleted file mode 100644 index 98e5ac26..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/discover.rs +++ /dev/null @@ -1,70 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -/// Type of content a discover slider shows. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "snake_case")] -pub enum DiscoverSliderType { - Trending, - PopularMovies, - PopularTv, - UpcomingMovies, - UpcomingTv, - RecentlyAdded, - MovieGenres, - TvGenres, - // Custom slider types (configured with custom_data) - TmdbMovieGenre, - TmdbTvGenre, - TmdbMovieKeyword, - TmdbTvKeyword, - TmdbSearch, - TmdbStudio, - TmdbNetwork, - TmdbMovieStreamingServices, - TmdbTvStreamingServices, -} - -/// A configurable discover slider for the homepage. -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct DiscoverSlider { - pub id: i64, - pub slider_type: DiscoverSliderType, - pub display_order: i32, - pub is_built_in: bool, - pub enabled: bool, - pub title: Option, - pub custom_data: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Input for creating a custom discover slider. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateDiscoverSliderInput { - pub slider_type: DiscoverSliderType, - pub title: Option, - pub custom_data: Option, - pub enabled: Option, -} - -/// Input for updating a discover slider. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateDiscoverSliderInput { - pub title: Option, - pub enabled: Option, - pub custom_data: Option, -} - -/// Input for reordering all sliders. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReorderSlidersInput { - /// Ordered list of slider IDs in the desired display order. - pub slider_ids: Vec, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/download.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/download.rs deleted file mode 100644 index 8665934e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/download.rs +++ /dev/null @@ -1,119 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -use super::media::{DownloadProtocol, MediaType}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum DownloadStatus { - Queued, - Downloading, - Paused, - PostProcessing, - Completed, - Failed, - Warning, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct QueueItem { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, - pub episode_id: Option, - pub title: String, - pub quality: serde_json::Value, - pub languages: Option, - pub size: Option, - pub status: DownloadStatus, - pub download_id: String, - pub download_client_id: Option, - pub indexer_id: Option, - pub protocol: DownloadProtocol, - pub error_message: Option, - pub added_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct DownloadClientConfig { - pub id: i64, - pub name: String, - pub client_type: String, - pub protocol: DownloadProtocol, - pub config: serde_json::Value, - pub enabled: bool, - pub priority: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct IndexerConfig { - pub id: i64, - pub name: String, - pub indexer_type: String, - pub base_url: String, - pub api_key: Option, - pub protocol: DownloadProtocol, - pub categories: Option>, - pub enabled: bool, - pub priority: i32, - pub supports_search: bool, - pub supports_rss: bool, - pub config: Option, - pub last_rss_sync: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReleaseInfo { - pub guid: String, - pub title: String, - pub download_url: Option, - pub info_url: Option, - pub indexer_id: i64, - pub indexer_name: String, - pub protocol: DownloadProtocol, - pub size: i64, - pub age_days: i64, - pub publish_date: DateTime, - // Torrent-specific - pub info_hash: Option, - pub magnet_url: Option, - pub seeders: Option, - pub leechers: Option, - // Usenet-specific - pub nzb_url: Option, - // External IDs from indexer - pub tvdb_id: Option, - pub imdb_id: Option, - pub tmdb_id: Option, - // Categories - pub categories: Vec, - pub indexer_flags: Vec, - /// Priority of the source indexer (lower = higher priority). - #[serde(default = "default_indexer_priority")] - pub indexer_priority: i32, -} - -fn default_indexer_priority() -> i32 { - 25 -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Blocklist { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, - pub source_title: String, - pub quality: serde_json::Value, - pub languages: Option, - pub indexer_id: Option, - pub info_hash: Option, - pub message: Option, - pub added_at: DateTime, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/history.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/history.rs deleted file mode 100644 index efcfd3c0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/history.rs +++ /dev/null @@ -1,35 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -use super::media::MediaType; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum HistoryEventType { - Grabbed, - Imported, - DownloadFailed, - FileDeleted, - FileRenamed, - DownloadIgnored, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct HistoryEvent { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, - pub episode_id: Option, - pub event_type: HistoryEventType, - pub quality: serde_json::Value, - pub languages: Option, - pub source_title: String, - pub download_id: Option, - pub indexer_id: Option, - pub download_client: Option, - pub data: Option, - pub occurred_at: DateTime, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/media.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/media.rs deleted file mode 100644 index 2e989325..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/media.rs +++ /dev/null @@ -1,210 +0,0 @@ -use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -// ---- Enums ---- - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum MediaType { - Series, - Movie, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum SeriesStatus { - Continuing, - Ended, - Upcoming, - Deleted, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum SeriesType { - Standard, - Daily, - Anime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum Availability { - Announced, - InCinemas, - Released, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "text", rename_all = "snake_case")] -#[serde(rename_all = "camelCase")] -pub enum DownloadProtocol { - Usenet, - Torrent, -} - -// ---- TV ---- - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Series { - pub id: i64, - pub title: String, - pub clean_title: String, - pub sort_title: String, - pub overview: Option, - pub status: SeriesStatus, - pub series_type: SeriesType, - pub network: Option, - pub air_time: Option, - pub first_aired: Option, - pub year: Option, - pub runtime: Option, - pub path: String, - pub media_library_folder_id: Option, - pub quality_profile_id: i32, - pub season_folder: bool, - pub monitored: bool, - pub use_scene_numbering: bool, - // External IDs - pub tvdb_id: Option, - pub imdb_id: Option, - pub tmdb_id: Option, - pub tvmaze_id: Option, - pub mal_id: Option, - pub images: Option, - pub genres: Option>, - pub tags: Option>, - pub added_at: DateTime, - pub last_info_sync: Option>, - // Plex integration - pub plex_rating_key: Option, - pub plex_rating_key_4k: Option, - pub media_added_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Season { - pub id: i64, - pub series_id: i64, - pub season_number: i32, - pub monitored: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Episode { - pub id: i64, - pub series_id: i64, - pub season_number: i32, - pub episode_number: i32, - pub absolute_number: Option, - pub scene_season_number: Option, - pub scene_episode_number: Option, - pub scene_absolute_number: Option, - pub title: Option, - pub overview: Option, - pub air_date: Option, - pub air_date_utc: Option>, - pub runtime: Option, - pub monitored: bool, - pub episode_file_id: Option, - pub last_search_time: Option>, -} - -// ---- Movies ---- - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Movie { - pub id: i64, - pub title: String, - pub clean_title: String, - pub sort_title: String, - pub overview: Option, - pub year: Option, - pub studio: Option, - pub path: String, - pub media_library_folder_id: Option, - pub quality_profile_id: i32, - pub monitored: bool, - pub minimum_availability: Availability, - pub movie_file_id: Option, - // External IDs - pub tmdb_id: Option, - pub imdb_id: Option, - // Dates - pub in_cinemas: Option, - pub physical_release: Option, - pub digital_release: Option, - pub images: Option, - pub genres: Option>, - pub tags: Option>, - pub collection_tmdb_id: Option, - pub added_at: DateTime, - pub last_info_sync: Option>, - // Plex integration - pub plex_rating_key: Option, - pub plex_rating_key_4k: Option, - pub media_added_at: Option>, -} - -// ---- Media Files (shared) ---- - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct MediaFile { - pub id: i64, - pub media_type: MediaType, - pub relative_path: String, - pub size: i64, - pub date_added: DateTime, - pub quality: serde_json::Value, - pub languages: serde_json::Value, - pub scene_name: Option, - pub release_group: Option, - pub release_hash: Option, - pub edition: Option, - pub media_info: Option, - pub indexer_flags: i32, -} - -// ---- Media Library Folders ---- - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct MediaLibraryFolder { - pub id: i64, - pub path: String, - pub media_type: MediaType, - pub free_space: Option, - pub last_checked: Option>, -} - -// ---- Alternative Titles ---- - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct AlternativeTitle { - pub id: i64, - pub media_type: MediaType, - pub media_id: i64, - pub title: String, - pub clean_title: String, - pub scene_name: bool, -} - -// ---- Tags ---- - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Tag { - pub id: i64, - pub label: String, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/quality.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/quality.rs deleted file mode 100644 index b90b4cf9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/quality.rs +++ /dev/null @@ -1,384 +0,0 @@ -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Quality { - Unknown = 0, - SDTV = 1, - DVD = 2, - WEBDL480p = 3, - WEBRip480p = 4, - Bluray480p = 5, - HDTV720p = 6, - WEBDL720p = 7, - WEBRip720p = 8, - Bluray720p = 9, - HDTV1080p = 10, - WEBDL1080p = 11, - WEBRip1080p = 12, - Bluray1080p = 13, - Remux1080p = 14, - HDTV2160p = 15, - WEBDL2160p = 16, - WEBRip2160p = 17, - Bluray2160p = 18, - Remux2160p = 19, - Raw = 20, -} - -impl Quality { - /// Try to convert an integer discriminant to a `Quality` variant. - pub fn from_id(id: i32) -> Option { - match id { - 0 => Some(Self::Unknown), - 1 => Some(Self::SDTV), - 2 => Some(Self::DVD), - 3 => Some(Self::WEBDL480p), - 4 => Some(Self::WEBRip480p), - 5 => Some(Self::Bluray480p), - 6 => Some(Self::HDTV720p), - 7 => Some(Self::WEBDL720p), - 8 => Some(Self::WEBRip720p), - 9 => Some(Self::Bluray720p), - 10 => Some(Self::HDTV1080p), - 11 => Some(Self::WEBDL1080p), - 12 => Some(Self::WEBRip1080p), - 13 => Some(Self::Bluray1080p), - 14 => Some(Self::Remux1080p), - 15 => Some(Self::HDTV2160p), - 16 => Some(Self::WEBDL2160p), - 17 => Some(Self::WEBRip2160p), - 18 => Some(Self::Bluray2160p), - 19 => Some(Self::Remux2160p), - 20 => Some(Self::Raw), - _ => None, - } - } - - pub fn name(&self) -> &'static str { - match self { - Self::Unknown => "Unknown", - Self::SDTV => "SDTV", - Self::DVD => "DVD", - Self::WEBDL480p => "WEBDL-480p", - Self::WEBRip480p => "WEBRip-480p", - Self::Bluray480p => "Bluray-480p", - Self::HDTV720p => "HDTV-720p", - Self::WEBDL720p => "WEBDL-720p", - Self::WEBRip720p => "WEBRip-720p", - Self::Bluray720p => "Bluray-720p", - Self::HDTV1080p => "HDTV-1080p", - Self::WEBDL1080p => "WEBDL-1080p", - Self::WEBRip1080p => "WEBRip-1080p", - Self::Bluray1080p => "Bluray-1080p", - Self::Remux1080p => "Remux-1080p", - Self::HDTV2160p => "HDTV-2160p", - Self::WEBDL2160p => "WEBDL-2160p", - Self::WEBRip2160p => "WEBRip-2160p", - Self::Bluray2160p => "Bluray-2160p", - Self::Remux2160p => "Remux-2160p", - Self::Raw => "Raw-HD", - } - } - - pub fn resolution(&self) -> Option { - match self { - Self::Unknown | Self::Raw => None, - Self::SDTV | Self::DVD => Some(480), - Self::WEBDL480p | Self::WEBRip480p | Self::Bluray480p => Some(480), - Self::HDTV720p | Self::WEBDL720p | Self::WEBRip720p | Self::Bluray720p => Some(720), - Self::HDTV1080p | Self::WEBDL1080p | Self::WEBRip1080p | Self::Bluray1080p | Self::Remux1080p => Some(1080), - Self::HDTV2160p | Self::WEBDL2160p | Self::WEBRip2160p | Self::Bluray2160p | Self::Remux2160p => Some(2160), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QualityModel { - pub quality: Quality, - pub revision: Revision, -} - -impl QualityModel { - pub fn new(quality: Quality) -> Self { - Self { - quality, - revision: Revision::default(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Revision { - pub version: i32, - pub real: i32, - pub is_repack: bool, -} - -impl Default for Revision { - fn default() -> Self { - Self { - version: 1, - real: 0, - is_repack: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct QualityProfile { - pub id: i32, - pub name: String, - pub cutoff: i32, - pub upgrade_allowed: bool, - pub min_format_score: i32, - pub cutoff_format_score: i32, - pub items: serde_json::Value, - pub media_type: Option, - /// Language preference: -1=Any (default), -2=Original, positive=specific Radarr language ID. - #[serde(default = "default_language_any")] - pub language: i32, -} - -fn default_language_any() -> i32 { - -1 -} - -impl QualityProfile { - /// Normalize the `items` JSONB so every entry has `quality: {id, name}`. - /// - /// Handles three formats: - /// - Bare integer: `{"quality": 10}` → `{"quality": {"id": 10, "name": "HDTV-1080p"}}` - /// - *arr object: `{"quality": {"id": 10, "name": "..."}}` → pass through - /// - Groups: items with nested `items` array and null quality → recurse - pub fn normalize_items(&mut self) { - self.items = normalize_items_value(&self.items); - } -} - -fn normalize_items_value(value: &serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::Array(arr) => { - serde_json::Value::Array(arr.iter().map(normalize_single_item).collect()) - } - _ => value.clone(), - } -} - -fn normalize_single_item(item: &serde_json::Value) -> serde_json::Value { - let Some(obj) = item.as_object() else { - return item.clone(); - }; - - let mut out = obj.clone(); - - // Normalize the quality field - if let Some(q) = obj.get("quality") { - match q { - // Bare integer → convert to {id, name} - serde_json::Value::Number(n) => { - if let Some(id) = n.as_i64().and_then(|n| i32::try_from(n).ok()) { - let name = Quality::from_id(id) - .map(|q| q.name()) - .unwrap_or("Unknown"); - out.insert( - "quality".to_string(), - serde_json::json!({"id": id, "name": name}), - ); - } - } - // Already an object — ensure it has a name - serde_json::Value::Object(qobj) => { - if qobj.contains_key("id") && !qobj.contains_key("name") { - let mut qobj = qobj.clone(); - if let Some(id) = qobj.get("id").and_then(|v| v.as_i64()).and_then(|n| i32::try_from(n).ok()) { - let name = Quality::from_id(id) - .map(|q| q.name()) - .unwrap_or("Unknown"); - qobj.insert("name".to_string(), serde_json::Value::String(name.to_string())); - } - out.insert("quality".to_string(), serde_json::Value::Object(qobj)); - } - } - _ => {} - } - } - - // Recursively normalize nested items (quality groups) - if let Some(nested) = obj.get("items") { - out.insert("items".to_string(), normalize_items_value(nested)); - } - - serde_json::Value::Object(out) -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct CustomFormat { - pub id: i32, - pub name: String, - pub specifications: serde_json::Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QualityProfileItem { - pub quality: Option, - pub allowed: bool, - #[serde(default)] - pub items: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Language { - English, - French, - Spanish, - German, - Italian, - Portuguese, - Japanese, - Korean, - Chinese, - Russian, - Arabic, - Hindi, - Dutch, - Swedish, - Norwegian, - Danish, - Finnish, - Polish, - Czech, - Hungarian, - Romanian, - Turkish, - Thai, - Vietnamese, - Indonesian, - Unknown, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_quality_name_mapping() { - // Every variant returns a non-empty name. - let variants = [ - Quality::Unknown, Quality::SDTV, Quality::DVD, Quality::WEBDL480p, - Quality::WEBRip480p, Quality::Bluray480p, Quality::HDTV720p, - Quality::WEBDL720p, Quality::WEBRip720p, Quality::Bluray720p, - Quality::HDTV1080p, Quality::WEBDL1080p, Quality::WEBRip1080p, - Quality::Bluray1080p, Quality::Remux1080p, Quality::HDTV2160p, - Quality::WEBDL2160p, Quality::WEBRip2160p, Quality::Bluray2160p, - Quality::Remux2160p, Quality::Raw, - ]; - for q in variants { - assert!(!q.name().is_empty(), "{q:?} returned empty name"); - } - } - - #[test] - fn test_quality_resolution() { - assert_eq!(Quality::Unknown.resolution(), None); - assert_eq!(Quality::Raw.resolution(), None); - assert_eq!(Quality::SDTV.resolution(), Some(480)); - assert_eq!(Quality::DVD.resolution(), Some(480)); - assert_eq!(Quality::HDTV720p.resolution(), Some(720)); - assert_eq!(Quality::WEBDL1080p.resolution(), Some(1080)); - assert_eq!(Quality::Bluray2160p.resolution(), Some(2160)); - assert_eq!(Quality::Remux2160p.resolution(), Some(2160)); - } - - #[test] - fn test_quality_ordering() { - assert!(Quality::Unknown < Quality::SDTV); - assert!(Quality::SDTV < Quality::DVD); - assert!(Quality::HDTV720p < Quality::HDTV1080p); - assert!(Quality::HDTV1080p < Quality::Bluray1080p); - assert!(Quality::Bluray1080p < Quality::Remux1080p); - assert!(Quality::Remux1080p < Quality::Bluray2160p); - assert!(Quality::Bluray2160p < Quality::Remux2160p); - } - - #[test] - fn test_quality_model_default_revision() { - let model = QualityModel::new(Quality::Bluray1080p); - assert_eq!(model.quality, Quality::Bluray1080p); - assert_eq!(model.revision.version, 1); - assert_eq!(model.revision.real, 0); - assert!(!model.revision.is_repack); - } - - #[test] - fn test_quality_model_serde_roundtrip() { - let model = QualityModel::new(Quality::WEBDL2160p); - let json = serde_json::to_string(&model).expect("serialize"); - let parsed: QualityModel = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(parsed.quality, Quality::WEBDL2160p); - assert_eq!(parsed.revision.version, 1); - } - - #[test] - fn test_quality_from_id() { - assert_eq!(Quality::from_id(0), Some(Quality::Unknown)); - assert_eq!(Quality::from_id(10), Some(Quality::HDTV1080p)); - assert_eq!(Quality::from_id(20), Some(Quality::Raw)); - assert_eq!(Quality::from_id(99), None); - assert_eq!(Quality::from_id(-1), None); - } - - #[test] - fn test_normalize_bare_integer_items() { - let items = serde_json::json!([ - {"quality": 10, "allowed": true}, - {"quality": 13, "allowed": false} - ]); - let normalized = super::normalize_items_value(&items); - let arr = normalized.as_array().unwrap(); - assert_eq!(arr[0]["quality"]["id"], 10); - assert_eq!(arr[0]["quality"]["name"], "HDTV-1080p"); - assert_eq!(arr[0]["allowed"], true); - assert_eq!(arr[1]["quality"]["id"], 13); - assert_eq!(arr[1]["quality"]["name"], "Bluray-1080p"); - } - - #[test] - fn test_normalize_arr_object_items() { - let items = serde_json::json!([ - {"quality": {"id": 10, "name": "HDTV-1080p"}, "allowed": true, "items": []} - ]); - let normalized = super::normalize_items_value(&items); - let arr = normalized.as_array().unwrap(); - assert_eq!(arr[0]["quality"]["id"], 10); - assert_eq!(arr[0]["quality"]["name"], "HDTV-1080p"); - } - - #[test] - fn test_normalize_nested_group() { - let items = serde_json::json!([ - { - "quality": null, - "allowed": true, - "items": [ - {"quality": 11, "allowed": true}, - {"quality": 12, "allowed": false} - ] - } - ]); - let normalized = super::normalize_items_value(&items); - let group = &normalized.as_array().unwrap()[0]; - let nested = group["items"].as_array().unwrap(); - assert_eq!(nested[0]["quality"]["id"], 11); - assert_eq!(nested[0]["quality"]["name"], "WEBDL-1080p"); - assert_eq!(nested[1]["quality"]["id"], 12); - assert_eq!(nested[1]["quality"]["name"], "WEBRip-1080p"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/user.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/user.rs deleted file mode 100644 index dac3a465..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/models/user.rs +++ /dev/null @@ -1,161 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct User { - pub id: i64, - pub username: String, - pub display_name: String, - #[serde(skip_serializing)] - pub password_hash: String, - pub role: String, - pub avatar_url: Option, - pub enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct UserSession { - pub id: Uuid, - pub user_id: i64, - pub token_hash: String, - pub user_agent: Option, - pub ip_address: Option, - pub created_at: DateTime, - pub expires_at: DateTime, - pub last_active: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct UserDevice { - pub id: i32, - pub user_id: i64, - pub device_token: Uuid, - pub device_name: Option, - pub device_type: Option, - pub created_at: DateTime, - pub last_seen: Option>, - pub revoked: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Invite { - pub id: i32, - pub code: String, - pub created_by: i64, - pub claimed_by: Option, - pub role: String, - pub expires_at: Option>, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct WatchProgress { - pub id: i64, - pub user_id: i64, - pub media_file_id: i64, - pub media_type: String, - pub media_id: i64, - pub episode_id: Option, - pub position_secs: f32, - pub duration_secs: f32, - pub completed: bool, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct MediaRequest { - pub id: i64, - pub user_id: i64, - pub media_type: String, - pub tmdb_id: i64, - pub title: String, - pub year: Option, - pub poster_url: Option, - pub overview: Option, - pub status: String, - pub admin_note: Option, - pub approved_by: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct UserWatchlistItem { - pub id: i64, - pub user_id: i64, - pub media_type: String, - pub media_id: i64, - pub tmdb_id: i64, - pub added_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct UserRating { - pub id: i64, - pub user_id: i64, - pub media_type: String, - pub media_id: i64, - pub rating: i16, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct UserNotification { - pub id: i64, - pub user_id: i64, - pub notification_type: String, - pub title: String, - pub body: Option, - pub data: Option, - pub read: bool, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct PushSubscription { - pub id: i64, - pub user_id: i64, - pub endpoint: String, - pub p256dh: String, - pub auth: String, - pub user_agent: Option, - pub created_at: DateTime, -} - -/// Enriched continue-watching item with joined media metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContinueWatchingItem { - pub id: i64, - pub user_id: i64, - pub media_file_id: i64, - pub media_type: String, - pub media_id: i64, - pub episode_id: Option, - pub position_secs: f32, - pub duration_secs: f32, - pub completed: bool, - pub updated_at: DateTime, - pub title: Option, - pub poster_url: Option, - pub backdrop_url: Option, - pub episode_title: Option, - pub season_number: Option, - pub episode_number: Option, - pub year: Option, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/notifications.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/notifications.rs deleted file mode 100644 index f1f522b2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/notifications.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::db::Database; - -/// High-level helpers for creating notifications. -pub struct NotificationService; - -impl NotificationService { - /// Notify all users about a new episode being available. - pub async fn notify_new_episode( - db: &Database, - series_title: &str, - season: i32, - episode: i32, - episode_title: Option<&str>, - ) -> crate::Result { - let title = format!("{series_title} - S{season:02}E{episode:02}"); - let body = episode_title.map(|t| format!("\"{t}\" is now available")); - let data = serde_json::json!({ - "type": "new_episode", - "seriesTitle": series_title, - "season": season, - "episode": episode, - "episodeTitle": episode_title, - }); - db.create_notification_for_all_users( - "new_episode", - &title, - body.as_deref(), - Some(data), - ) - .await - } - - /// Notify all users about a new movie being available. - pub async fn notify_new_movie( - db: &Database, - movie_title: &str, - year: Option, - ) -> crate::Result { - let title = match year { - Some(y) => format!("{movie_title} ({y})"), - None => movie_title.to_string(), - }; - let body = Some("is now available".to_string()); - let data = serde_json::json!({ - "type": "new_movie", - "movieTitle": movie_title, - "year": year, - }); - db.create_notification_for_all_users( - "new_movie", - &title, - body.as_deref(), - Some(data), - ) - .await - } - - /// Notify a specific user about a media request status change. - pub async fn notify_request_update( - db: &Database, - user_id: i64, - request_title: &str, - new_status: &str, - ) -> crate::Result { - let title = format!("Request Update: {request_title}"); - let body = match new_status { - "approved" => Some("Your request has been approved".to_string()), - "declined" => Some("Your request has been declined".to_string()), - "available" => Some("Your requested media is now available".to_string()), - _ => Some(format!("Status changed to: {new_status}")), - }; - let data = serde_json::json!({ - "type": "request_update", - "title": request_title, - "status": new_status, - }); - db.create_notification( - user_id, - "request_update", - &title, - body.as_deref(), - Some(data), - ) - .await - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/test_helpers.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/test_helpers.rs deleted file mode 100644 index a45269a1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-core/src/test_helpers.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Test utilities for database-backed integration tests. -//! -//! Requires a running PostgreSQL instance (e.g. `docker compose -f docker/docker-compose.dev.yml up -d`). -//! Set `TEST_DATABASE_URL` to override the default connection string. - -use sqlx::postgres::PgPoolOptions; -use sqlx::{Executor, PgPool}; - -/// Default Postgres URL matching docker-compose.dev.yml. -const DEFAULT_TEST_URL: &str = "postgresql://stackarr:stackarr@localhost:5433/postgres"; - -fn base_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| DEFAULT_TEST_URL.to_string()) -} - -/// A guard that owns a test database and drops it when the guard goes out of scope. -pub struct TestDb { - pub pool: PgPool, - pub name: String, - base_url: String, -} - -impl TestDb { - /// Create a fresh database with a random name, run all migrations, and return - /// a pool connected to it. - pub async fn new() -> Self { - let base = base_url(); - let name = format!("stackarr_test_{}", uuid::Uuid::new_v4().simple()); - - // Connect to the maintenance database to create a new test database. - let admin_pool = PgPoolOptions::new() - .max_connections(2) - .connect(&base) - .await - .expect("failed to connect to admin postgres — is docker compose running?"); - - admin_pool - .execute(format!("CREATE DATABASE \"{name}\"").as_str()) - .await - .expect("failed to create test database"); - - admin_pool.close().await; - - // Build connection URL for the new test database. - let test_url = if let Some(pos) = base.rfind('/') { - format!("{}/{name}", &base[..pos]) - } else { - panic!("TEST_DATABASE_URL must include a database name component"); - }; - - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(&test_url) - .await - .expect("failed to connect to test database"); - - // Run migrations. - sqlx::migrate!("../../migrations") - .run(&pool) - .await - .expect("migration failed on test database"); - - Self { - pool, - name, - base_url: base, - } - } -} - -impl TestDb { - /// Explicitly drop the test database. Call this at the end of your test - /// instead of relying on `Drop` for reliable cleanup. - pub async fn cleanup(self) { - let base = self.base_url.clone(); - let name = self.name.clone(); - self.pool.close().await; - if let Ok(admin) = PgPoolOptions::new() - .max_connections(2) - .connect(&base) - .await - { - let _ = admin - .execute(format!("DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)").as_str()) - .await; - admin.close().await; - } - } -} - -// --------------------------------------------------------------------------- -// Seed helpers — insert minimal rows so foreign-key dependent tests work. -// --------------------------------------------------------------------------- - -/// Insert a minimal quality profile and return its id. -pub async fn seed_quality_profile(pool: &PgPool) -> i32 { - let row: (i32,) = sqlx::query_as( - "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items) - VALUES ('Test Profile', 6, true, 0, 0, '[]'::jsonb) RETURNING id", - ) - .fetch_one(pool) - .await - .expect("seed quality profile"); - row.0 -} - -/// Insert a media library folder and return its id. -pub async fn seed_media_library_folder(pool: &PgPool, path: &str, media_type: &str) -> i32 { - let row: (i32,) = sqlx::query_as( - "INSERT INTO media_library_folders (path, media_type) VALUES ($1, $2) RETURNING id", - ) - .bind(path) - .bind(media_type) - .fetch_one(pool) - .await - .expect("seed media library folder"); - row.0 -} - -/// Insert a test series and return its id. -pub async fn seed_series(pool: &PgPool, title: &str, profile_id: i32, media_library_folder_id: i32) -> i64 { - let clean = title.to_lowercase().replace(' ', ""); - let row: (i64,) = sqlx::query_as( - "INSERT INTO series (title, clean_title, sort_title, path, quality_profile_id, media_library_folder_id, monitored) - VALUES ($1, $2, $3, $4, $5, $6, true) RETURNING id", - ) - .bind(title) - .bind(&clean) - .bind(&clean) - .bind(format!("/tv/{title}")) - .bind(profile_id) - .bind(media_library_folder_id) - .fetch_one(pool) - .await - .expect("seed series"); - row.0 -} - -/// Insert a test movie and return its id. -pub async fn seed_movie(pool: &PgPool, title: &str, profile_id: i32, media_library_folder_id: i32) -> i64 { - let clean = title.to_lowercase().replace(' ', ""); - let row: (i64,) = sqlx::query_as( - "INSERT INTO movies (title, clean_title, sort_title, path, quality_profile_id, media_library_folder_id, monitored, minimum_availability) - VALUES ($1, $2, $3, $4, $5, $6, true, 'released') RETURNING id", - ) - .bind(title) - .bind(&clean) - .bind(&clean) - .bind(format!("/movies/{title}")) - .bind(profile_id) - .bind(media_library_folder_id) - .fetch_one(pool) - .await - .expect("seed movie"); - row.0 -} - -/// Insert a test episode and return its id. -pub async fn seed_episode( - pool: &PgPool, - series_id: i64, - season: i32, - episode: i32, -) -> i64 { - let row: (i64,) = sqlx::query_as( - "INSERT INTO episodes (series_id, season_number, episode_number, title, monitored) - VALUES ($1, $2, $3, $4, true) RETURNING id", - ) - .bind(series_id) - .bind(season) - .bind(episode) - .bind(format!("Episode {episode}")) - .fetch_one(pool) - .await - .expect("seed episode"); - row.0 -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/Cargo.toml deleted file mode 100644 index bee13a23..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "stackarr-download" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[features] -default = [] - -[dependencies] -stackarr-core = { workspace = true } -async-trait = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -reqwest = { workspace = true, features = ["cookies", "multipart"] } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -librtbit = { workspace = true, features = ["default-tls", "http-api"] } -librtbit-core = { workspace = true } -nzb-core = { workspace = true } -nzb-web = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/client.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/client.rs deleted file mode 100644 index 421e0cb0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/client.rs +++ /dev/null @@ -1,125 +0,0 @@ -use std::path::PathBuf; - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -// ── Protocol enum (local copy to avoid circular dep with stackarr-core) ───── - -/// Whether a release is fetched via Usenet or BitTorrent. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DownloadProtocol { - Usenet, - Torrent, -} - -impl std::fmt::Display for DownloadProtocol { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Usenet => write!(f, "usenet"), - Self::Torrent => write!(f, "torrent"), - } - } -} - -// ── Request / response types ──────────────────────────────────────────────── - -/// A request to add a download to a client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GrabRequest { - pub title: String, - pub download_url: String, - pub category: Option, - pub protocol: DownloadProtocol, -} - -/// Represents a single item inside a download client's queue / history. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DownloadItem { - pub download_id: String, - pub title: String, - pub status: DownloadItemStatus, - pub total_size: u64, - pub remaining_size: u64, - pub output_path: Option, - pub category: Option, - pub can_move_files: bool, - pub can_be_removed: bool, - pub protocol: DownloadProtocol, -} - -/// Fine-grained status for a download item. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum DownloadItemStatus { - Queued, - Downloading, - Paused, - Completed, - Failed, - Warning, - Seeding, - Extracting, - Verifying, -} - -impl std::fmt::Display for DownloadItemStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::Queued => "queued", - Self::Downloading => "downloading", - Self::Paused => "paused", - Self::Completed => "completed", - Self::Failed => "failed", - Self::Warning => "warning", - Self::Seeding => "seeding", - Self::Extracting => "extracting", - Self::Verifying => "verifying", - }; - write!(f, "{s}") - } -} - -/// Status report returned by [`DownloadClient::status`]. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientStatus { - pub name: String, - pub protocol: DownloadProtocol, - pub version: String, - pub is_connected: bool, -} - -// ── Trait ──────────────────────────────────────────────────────────────────── - -/// Abstraction over an external download client (qBittorrent, Transmission, -/// SABnzbd, NZBGet, ...). -#[async_trait] -pub trait DownloadClient: Send + Sync { - /// Human-readable client name (e.g. "qBittorrent"). - fn name(&self) -> &str; - - /// Which protocol this client supports. - fn protocol(&self) -> DownloadProtocol; - - /// Add a new download. - async fn add(&self, request: &GrabRequest) -> anyhow::Result; - - /// List all items currently tracked by the client. - async fn get_items(&self) -> anyhow::Result>; - - /// Remove a download by its client-side ID. - async fn remove(&self, id: &str, delete_data: bool) -> anyhow::Result<()>; - - /// Pause a download. - async fn pause(&self, id: &str) -> anyhow::Result<()>; - - /// Resume a paused download. - async fn resume(&self, id: &str) -> anyhow::Result<()>; - - /// Quick connectivity / auth test. - async fn test(&self) -> anyhow::Result<()>; - - /// Retrieve version and connection info. - async fn status(&self) -> anyhow::Result; -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/embedded_torrent.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/embedded_torrent.rs deleted file mode 100644 index e4f8ae79..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/embedded_torrent.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::sync::Arc; - -use librtbit::Session; - -/// Wrapper around the embedded librtbit torrent session. -pub struct EmbeddedTorrentClient { - session: Arc, -} - -impl EmbeddedTorrentClient { - /// Create a new embedded torrent client with the given session. - pub fn new(session: Arc) -> Self { - Self { session } - } - - /// Get a reference to the underlying librtbit session. - pub fn session(&self) -> &Arc { - &self.session - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/embedded_usenet.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/embedded_usenet.rs deleted file mode 100644 index 3532bcd4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/embedded_usenet.rs +++ /dev/null @@ -1,17 +0,0 @@ -/// Wrapper around the embedded nzb usenet download engine. -pub struct EmbeddedUsenetClient { - // TODO: hold nzb-web engine handle once integrated -} - -impl EmbeddedUsenetClient { - /// Create a new embedded usenet client. - pub fn new() -> Self { - Self {} - } -} - -impl Default for EmbeddedUsenetClient { - fn default() -> Self { - Self::new() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/factory.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/factory.rs deleted file mode 100644 index b74d959c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/factory.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::client::DownloadClient; - -/// Build a download client instance from a `client_type` string and JSON config. -/// -/// This is used both at startup (loading from DB) and by the health checker -/// when re-enabling auto-disabled clients. -pub fn build_from_config( - client_type: &str, - config: &serde_json::Value, -) -> anyhow::Result> { - match client_type.to_ascii_lowercase().as_str() { - "qbittorrent" => { - let host = config["host"].as_str().unwrap_or("http://localhost:8080"); - let username = config["username"].as_str().unwrap_or(""); - let password = config["password"].as_str().unwrap_or(""); - Ok(Box::new(crate::qbittorrent::QBittorrentClient::new( - host, username, password, - ))) - } - "transmission" => { - let host = config["host"].as_str().unwrap_or("http://localhost:9091"); - let username = config["username"].as_str().map(String::from); - let password = config["password"].as_str().map(String::from); - Ok(Box::new(crate::transmission::TransmissionClient::new( - host, username, password, - ))) - } - "sabnzbd" => { - let host = config["host"].as_str().unwrap_or("http://localhost:8080"); - let api_key = config["apiKey"].as_str().unwrap_or(""); - Ok(Box::new(crate::sabnzbd::SabnzbdClient::new(host, api_key))) - } - "nzbget" => { - let host = config["host"].as_str().unwrap_or("http://localhost:6789"); - let username = config["username"].as_str().unwrap_or(""); - let password = config["password"].as_str().unwrap_or(""); - Ok(Box::new(crate::nzbget::NzbgetClient::new( - host, username, password, - ))) - } - "embedded_usenet" => { - // Embedded usenet servers are managed by the nzb engine, not the download client manager - anyhow::bail!("embedded_usenet is managed by the usenet engine, not download clients") - } - other => anyhow::bail!("unknown download client type: {other}"), - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/lib.rs deleted file mode 100644 index db5fc3ae..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod client; -pub mod embedded_torrent; -pub mod embedded_usenet; -pub mod factory; -pub mod manager; -pub mod nzbget; -pub mod qbittorrent; -pub mod rtbit; -pub mod sabnzbd; -pub mod transmission; - -pub use client::*; -pub use factory::build_from_config; -pub use manager::DownloadClientManager; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/manager.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/manager.rs deleted file mode 100644 index 5193a0cf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/manager.rs +++ /dev/null @@ -1,278 +0,0 @@ -use anyhow::bail; -use tracing::warn; - -use crate::client::{DownloadClient, DownloadItem, DownloadProtocol, GrabRequest}; - -struct ManagedClient { - id: i64, - client: Box, - enabled: bool, - priority: i32, -} - -/// Manages a collection of download clients and dispatches operations to them. -pub struct DownloadClientManager { - clients: Vec, -} - -impl DownloadClientManager { - pub fn new() -> Self { - Self { - clients: Vec::new(), - } - } - - /// Register a download client with a database ID and priority (lower = higher priority). - pub fn add_client(&mut self, id: i64, client: Box, priority: i32) { - self.clients.push(ManagedClient { id, client, enabled: true, priority }); - } - - /// Remove a client by database ID. - pub fn remove_client(&mut self, id: i64) -> bool { - let before = self.clients.len(); - self.clients.retain(|c| c.id != id); - self.clients.len() < before - } - - /// Enable or disable a client without removing it. - pub fn set_enabled(&mut self, id: i64, enabled: bool) { - if let Some(c) = self.clients.iter_mut().find(|c| c.id == id) { - c.enabled = enabled; - } - } - - /// Get a reference to a specific client by database ID (regardless of enabled state). - pub fn client_by_id(&self, id: i64) -> Option<&dyn DownloadClient> { - self.clients - .iter() - .find(|c| c.id == id) - .map(|c| c.client.as_ref()) - } - - /// List all registered client IDs (for health check iteration). - pub fn all_client_ids(&self) -> Vec { - self.clients.iter().map(|c| c.id).collect() - } - - /// Poll every enabled client and aggregate their download items. - pub async fn get_items_all(&self) -> Vec<(i64, Vec)> { - let mut results = Vec::new(); - for c in &self.clients { - if !c.enabled { - continue; - } - match c.client.get_items().await { - Ok(items) => results.push((c.id, items)), - Err(e) => { - warn!(client = c.client.name(), error = %e, "failed to poll download client"); - } - } - } - results - } - - /// Send a grab request to the highest-priority enabled client matching the - /// requested protocol. Falls back to the next client on failure. - pub async fn grab(&self, request: &GrabRequest) -> anyhow::Result<(i64, String)> { - let mut candidates: Vec<&ManagedClient> = self - .clients - .iter() - .filter(|c| c.enabled && c.client.protocol() == request.protocol) - .collect(); - candidates.sort_by_key(|c| c.priority); - - for c in candidates { - match c.client.add(request).await { - Ok(download_id) => return Ok((c.id, download_id)), - Err(e) => { - warn!(client = c.client.name(), error = %e, "download client failed, trying next"); - } - } - } - bail!("no {} download client available", request.protocol); - } - - /// Return the number of enabled clients. - pub fn len(&self) -> usize { - self.clients.iter().filter(|c| c.enabled).count() - } - - /// Whether there are no enabled clients. - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - /// List the registered enabled (id, protocol) pairs. - pub fn registered(&self) -> Vec<(i64, DownloadProtocol)> { - self.clients - .iter() - .filter(|c| c.enabled) - .map(|c| (c.id, c.client.protocol())) - .collect() - } -} - -impl Default for DownloadClientManager { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::client::{ClientStatus, DownloadItemStatus}; - - struct MockClient { - id_name: String, - proto: DownloadProtocol, - } - - impl MockClient { - fn torrent(name: &str) -> Self { - Self { id_name: name.to_string(), proto: DownloadProtocol::Torrent } - } - fn usenet(name: &str) -> Self { - Self { id_name: name.to_string(), proto: DownloadProtocol::Usenet } - } - } - - #[async_trait::async_trait] - impl DownloadClient for MockClient { - fn name(&self) -> &str { &self.id_name } - fn protocol(&self) -> DownloadProtocol { self.proto } - async fn add(&self, _request: &GrabRequest) -> anyhow::Result { - Ok(format!("mock-dl-{}", self.id_name)) - } - async fn get_items(&self) -> anyhow::Result> { - Ok(vec![DownloadItem { - download_id: "item-1".into(), - title: "Test Download".into(), - status: DownloadItemStatus::Downloading, - total_size: 1_000_000, - remaining_size: 500_000, - output_path: None, - category: None, - can_move_files: true, - can_be_removed: true, - protocol: self.proto, - }]) - } - async fn remove(&self, _id: &str, _delete_data: bool) -> anyhow::Result<()> { Ok(()) } - async fn pause(&self, _id: &str) -> anyhow::Result<()> { Ok(()) } - async fn resume(&self, _id: &str) -> anyhow::Result<()> { Ok(()) } - async fn test(&self) -> anyhow::Result<()> { Ok(()) } - async fn status(&self) -> anyhow::Result { - Ok(ClientStatus { - name: self.id_name.clone(), - protocol: self.proto, - version: "1.0".into(), - is_connected: true, - }) - } - } - - #[test] - fn test_manager_new_is_empty() { - let mgr = DownloadClientManager::new(); - assert!(mgr.is_empty()); - assert_eq!(mgr.len(), 0); - } - - #[test] - fn test_manager_add_client() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - assert_eq!(mgr.len(), 1); - assert!(!mgr.is_empty()); - } - - #[test] - fn test_manager_remove_client() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - mgr.add_client(2, Box::new(MockClient::usenet("sab")), 5); - assert!(mgr.remove_client(1)); - assert_eq!(mgr.len(), 1); - } - - #[test] - fn test_manager_remove_nonexistent() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - assert!(!mgr.remove_client(999)); - assert_eq!(mgr.len(), 1); - } - - #[test] - fn test_manager_client_by_id() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(5, Box::new(MockClient::torrent("qbit")), 5); - let client = mgr.client_by_id(5); - assert!(client.is_some()); - assert_eq!(client.unwrap().name(), "qbit"); - } - - #[test] - fn test_manager_client_by_id_missing() { - let mgr = DownloadClientManager::new(); - assert!(mgr.client_by_id(1).is_none()); - } - - #[test] - fn test_manager_registered() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - mgr.add_client(2, Box::new(MockClient::usenet("sab")), 5); - let pairs = mgr.registered(); - assert_eq!(pairs.len(), 2); - assert_eq!(pairs[0], (1, DownloadProtocol::Torrent)); - assert_eq!(pairs[1], (2, DownloadProtocol::Usenet)); - } - - #[tokio::test] - async fn test_manager_grab_selects_protocol() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - mgr.add_client(2, Box::new(MockClient::usenet("sab")), 5); - - let req = GrabRequest { - title: "Test Release".into(), - download_url: "http://example.com/dl".into(), - category: None, - protocol: DownloadProtocol::Usenet, - }; - let (id, dl_id) = mgr.grab(&req).await.expect("grab should succeed"); - assert_eq!(id, 2); - assert_eq!(dl_id, "mock-dl-sab"); - } - - #[tokio::test] - async fn test_manager_grab_no_matching_protocol() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - - let req = GrabRequest { - title: "Test".into(), - download_url: "http://example.com/dl".into(), - category: None, - protocol: DownloadProtocol::Usenet, - }; - let result = mgr.grab(&req).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("usenet")); - } - - #[tokio::test] - async fn test_manager_get_items_all() { - let mut mgr = DownloadClientManager::new(); - mgr.add_client(1, Box::new(MockClient::torrent("qbit")), 5); - mgr.add_client(2, Box::new(MockClient::usenet("sab")), 5); - - let items = mgr.get_items_all().await; - assert_eq!(items.len(), 2); - assert_eq!(items[0].0, 1); - assert_eq!(items[0].1.len(), 1); - assert_eq!(items[1].0, 2); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/nzbget.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/nzbget.rs deleted file mode 100644 index 4618befd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/nzbget.rs +++ /dev/null @@ -1,273 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, bail}; -use async_trait::async_trait; -use reqwest::Client; -use serde::Deserialize; -use serde_json::{Value, json}; - -use crate::client::{ - ClientStatus, DownloadClient, DownloadItem, DownloadItemStatus, DownloadProtocol, GrabRequest, -}; - -/// NZBGet JSON-RPC client. -pub struct NzbgetClient { - base_url: String, - username: String, - password: String, - http: Client, -} - -impl NzbgetClient { - pub fn new( - base_url: impl Into, - username: impl Into, - password: impl Into, - ) -> Self { - Self { - base_url: base_url.into().trim_end_matches('/').to_string(), - username: username.into(), - password: password.into(), - http: Client::new(), - } - } - - fn rpc_url(&self) -> String { - format!("{}/jsonrpc", self.base_url) - } - - async fn rpc(&self, method: &str, params: Vec) -> anyhow::Result { - let body = json!({ - "method": method, - "params": params, - }); - let resp = self - .http - .post(self.rpc_url()) - .basic_auth(&self.username, Some(&self.password)) - .json(&body) - .send() - .await - .context("NZBGet RPC request failed")?; - - if !resp.status().is_success() { - bail!("NZBGet RPC returned HTTP {}", resp.status()); - } - - let result: Value = resp.json().await.context("failed to parse NZBGet response")?; - if let Some(err) = result.get("error") { - if !err.is_null() { - bail!("NZBGet RPC error: {err}"); - } - } - Ok(result["result"].clone()) - } -} - -// ── Wire types ────────────────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct NzbGroup { - #[serde(rename = "NZBID")] - nzbid: i64, - #[serde(rename = "NZBName")] - nzb_name: String, - status: String, - #[serde(rename = "FileSizeLo")] - file_size_lo: u64, - #[serde(rename = "FileSizeHi")] - file_size_hi: u64, - #[serde(rename = "RemainingSizeLo")] - remaining_size_lo: u64, - #[serde(rename = "RemainingSizeHi")] - remaining_size_hi: u64, - #[serde(rename = "DestDir")] - dest_dir: Option, - category: Option, -} - -impl NzbGroup { - fn total_size(&self) -> u64 { - self.file_size_lo | (self.file_size_hi << 32) - } - - fn remaining_size(&self) -> u64 { - self.remaining_size_lo | (self.remaining_size_hi << 32) - } - - fn to_item(&self) -> DownloadItem { - DownloadItem { - download_id: self.nzbid.to_string(), - title: self.nzb_name.clone(), - status: map_nzbget_status(&self.status), - total_size: self.total_size(), - remaining_size: self.remaining_size(), - output_path: self.dest_dir.as_ref().map(PathBuf::from), - category: self.category.clone(), - can_move_files: true, - can_be_removed: true, - protocol: DownloadProtocol::Usenet, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct NzbHistoryItem { - #[serde(rename = "NZBID")] - nzbid: i64, - name: String, - status: String, - #[serde(rename = "FileSizeLo")] - file_size_lo: u64, - #[serde(rename = "FileSizeHi")] - file_size_hi: u64, - #[serde(rename = "DestDir")] - dest_dir: Option, - category: Option, -} - -impl NzbHistoryItem { - fn total_size(&self) -> u64 { - self.file_size_lo | (self.file_size_hi << 32) - } - - fn to_item(&self) -> DownloadItem { - let status = if self.status.starts_with("SUCCESS") { - DownloadItemStatus::Completed - } else { - DownloadItemStatus::Failed - }; - DownloadItem { - download_id: self.nzbid.to_string(), - title: self.name.clone(), - status, - total_size: self.total_size(), - remaining_size: 0, - output_path: self.dest_dir.as_ref().map(PathBuf::from), - category: self.category.clone(), - can_move_files: true, - can_be_removed: true, - protocol: DownloadProtocol::Usenet, - } - } -} - -fn map_nzbget_status(status: &str) -> DownloadItemStatus { - match status { - "DOWNLOADING" => DownloadItemStatus::Downloading, - "PAUSED" | "QUEUED_PAUSED" => DownloadItemStatus::Paused, - "QUEUED" => DownloadItemStatus::Queued, - "PP_QUEUED" | "LOADING_PARS" | "VERIFYING_SOURCES" | "REPAIRING" - | "VERIFYING_REPAIRED" | "RENAMING" | "UNPACKING" | "MOVING" - | "EXECUTING_SCRIPT" => DownloadItemStatus::Extracting, - _ => DownloadItemStatus::Queued, - } -} - -// ── DownloadClient impl ───────────────────────────────────────────────────── - -#[async_trait] -impl DownloadClient for NzbgetClient { - fn name(&self) -> &str { - "NZBGet" - } - - fn protocol(&self) -> DownloadProtocol { - DownloadProtocol::Usenet - } - - async fn add(&self, request: &GrabRequest) -> anyhow::Result { - // append(NZBFilename, URL, Category, Priority, AddToTop, AddPaused, - // DupeKey, DupeScore, DupeMode, PPParameters) - let category = request.category.as_deref().unwrap_or(""); - let params = vec![ - json!(""), // NZBFilename - json!(request.download_url), // URL - json!(category), // Category - json!(0), // Priority (normal) - json!(false), // AddToTop - json!(false), // AddPaused - json!(""), // DupeKey - json!(0), // DupeScore - json!("score"), // DupeMode - json!([]), // PPParameters - ]; - let result = self.rpc("append", params).await?; - let id = result.as_i64().unwrap_or(0); - if id <= 0 { - bail!("NZBGet append returned invalid ID: {result}"); - } - Ok(id.to_string()) - } - - async fn get_items(&self) -> anyhow::Result> { - let mut items = Vec::new(); - - // Active queue - let groups_val = self.rpc("listgroups", vec![]).await?; - if let Ok(groups) = serde_json::from_value::>(groups_val) { - for g in &groups { - items.push(g.to_item()); - } - } - - // History - let hist_val = self.rpc("history", vec![json!(false)]).await?; - if let Ok(history) = serde_json::from_value::>(hist_val) { - for h in &history { - items.push(h.to_item()); - } - } - - Ok(items) - } - - async fn remove(&self, id: &str, _delete_data: bool) -> anyhow::Result<()> { - let nzbid: i64 = id.parse().unwrap_or(0); - // editqueue(Command, Offset, EditText, IDs) - self.rpc( - "editqueue", - vec![json!("GroupDelete"), json!(0), json!(""), json!([nzbid])], - ) - .await?; - Ok(()) - } - - async fn pause(&self, id: &str) -> anyhow::Result<()> { - let nzbid: i64 = id.parse().unwrap_or(0); - self.rpc( - "editqueue", - vec![json!("GroupPause"), json!(0), json!(""), json!([nzbid])], - ) - .await?; - Ok(()) - } - - async fn resume(&self, id: &str) -> anyhow::Result<()> { - let nzbid: i64 = id.parse().unwrap_or(0); - self.rpc( - "editqueue", - vec![json!("GroupResume"), json!(0), json!(""), json!([nzbid])], - ) - .await?; - Ok(()) - } - - async fn test(&self) -> anyhow::Result<()> { - self.rpc("version", vec![]).await?; - Ok(()) - } - - async fn status(&self) -> anyhow::Result { - let result = self.rpc("version", vec![]).await?; - let version = result.as_str().unwrap_or("unknown").to_string(); - Ok(ClientStatus { - name: "NZBGet".to_string(), - protocol: DownloadProtocol::Usenet, - version, - is_connected: true, - }) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/qbittorrent.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/qbittorrent.rs deleted file mode 100644 index 61c3ac3e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/qbittorrent.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context, bail}; -use async_trait::async_trait; -use reqwest::Client; -use serde::Deserialize; -use tracing::{debug, warn}; - -use crate::client::{ - ClientStatus, DownloadClient, DownloadItem, DownloadItemStatus, DownloadProtocol, GrabRequest, -}; - -/// qBittorrent WebUI API v2 client. -pub struct QBittorrentClient { - base_url: String, - username: String, - password: String, - http: Arc, -} - -impl QBittorrentClient { - /// Create a new client. The underlying `reqwest::Client` is built with a - /// cookie store so the SID cookie persists across requests. - pub fn new(base_url: impl Into, username: impl Into, password: impl Into) -> Self { - let http = Client::builder() - .cookie_store(true) - .build() - .expect("failed to build reqwest client"); - Self { - base_url: base_url.into().trim_end_matches('/').to_string(), - username: username.into(), - password: password.into(), - http: Arc::new(http), - } - } - - /// Authenticate and obtain a session cookie. - async fn login(&self) -> anyhow::Result<()> { - let url = format!("{}/api/v2/auth/login", self.base_url); - let resp = self - .http - .post(&url) - .form(&[("username", &self.username), ("password", &self.password)]) - .send() - .await - .context("qBittorrent login request failed")?; - - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - if status.is_success() && body.contains("Ok") { - debug!("qBittorrent login successful"); - Ok(()) - } else { - bail!("qBittorrent login failed (HTTP {status}): {body}"); - } - } -} - -// ── API response types ────────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -struct QBTorrent { - hash: String, - name: String, - state: String, - size: u64, - #[serde(default)] - amount_left: u64, - save_path: Option, - category: Option, -} - -impl QBTorrent { - fn to_item(&self) -> DownloadItem { - DownloadItem { - download_id: self.hash.clone(), - title: self.name.clone(), - status: map_qb_state(&self.state), - total_size: self.size, - remaining_size: self.amount_left, - output_path: self.save_path.as_ref().map(PathBuf::from), - category: self.category.clone(), - can_move_files: true, - can_be_removed: true, - protocol: DownloadProtocol::Torrent, - } - } -} - -fn map_qb_state(state: &str) -> DownloadItemStatus { - match state { - "error" | "missingFiles" => DownloadItemStatus::Failed, - "uploading" | "stalledUP" | "forcedUP" | "queuedUP" => DownloadItemStatus::Seeding, - "pausedDL" | "pausedUP" => DownloadItemStatus::Paused, - "queuedDL" | "metaDL" | "allocating" => DownloadItemStatus::Queued, - "downloading" | "stalledDL" | "forcedDL" => DownloadItemStatus::Downloading, - "checkingDL" | "checkingUP" | "checkingResumeData" => DownloadItemStatus::Verifying, - "moving" => DownloadItemStatus::Extracting, - _ => DownloadItemStatus::Queued, - } -} - -// ── DownloadClient impl ───────────────────────────────────────────────────── - -#[async_trait] -impl DownloadClient for QBittorrentClient { - fn name(&self) -> &str { - "qBittorrent" - } - - fn protocol(&self) -> DownloadProtocol { - DownloadProtocol::Torrent - } - - async fn add(&self, request: &GrabRequest) -> anyhow::Result { - self.login().await?; - let url = format!("{}/api/v2/torrents/add", self.base_url); - - let mut form = reqwest::multipart::Form::new().text("urls", request.download_url.clone()); - if let Some(cat) = &request.category { - form = form.text("category", cat.clone()); - } - - let resp = self - .http - .post(&url) - .multipart(form) - .send() - .await - .context("qBittorrent add torrent request failed")?; - - if resp.status().is_success() { - Ok(String::from("ok")) - } else { - let body = resp.text().await.unwrap_or_default(); - bail!("qBittorrent add failed: {body}"); - } - } - - async fn get_items(&self) -> anyhow::Result> { - self.login().await?; - let url = format!("{}/api/v2/torrents/info", self.base_url); - let resp = self - .http - .get(&url) - .send() - .await - .context("qBittorrent torrents/info request failed")?; - - let torrents: Vec = resp.json().await.context("failed to parse qBittorrent torrent list")?; - Ok(torrents.iter().map(QBTorrent::to_item).collect()) - } - - async fn remove(&self, id: &str, delete_data: bool) -> anyhow::Result<()> { - self.login().await?; - let url = format!("{}/api/v2/torrents/delete", self.base_url); - self.http - .post(&url) - .form(&[ - ("hashes", id), - ("deleteFiles", if delete_data { "true" } else { "false" }), - ]) - .send() - .await - .context("qBittorrent delete request failed")?; - Ok(()) - } - - async fn pause(&self, id: &str) -> anyhow::Result<()> { - self.login().await?; - let url = format!("{}/api/v2/torrents/pause", self.base_url); - self.http - .post(&url) - .form(&[("hashes", id)]) - .send() - .await - .context("qBittorrent pause request failed")?; - Ok(()) - } - - async fn resume(&self, id: &str) -> anyhow::Result<()> { - self.login().await?; - let url = format!("{}/api/v2/torrents/resume", self.base_url); - self.http - .post(&url) - .form(&[("hashes", id)]) - .send() - .await - .context("qBittorrent resume request failed")?; - Ok(()) - } - - async fn test(&self) -> anyhow::Result<()> { - self.login().await?; - let url = format!("{}/api/v2/app/version", self.base_url); - let resp = self.http.get(&url).send().await?; - if resp.status().is_success() { - Ok(()) - } else { - bail!("qBittorrent test failed: HTTP {}", resp.status()); - } - } - - async fn status(&self) -> anyhow::Result { - self.login().await?; - let url = format!("{}/api/v2/app/version", self.base_url); - let resp = self.http.get(&url).send().await?; - let version = if resp.status().is_success() { - resp.text().await.unwrap_or_default() - } else { - warn!("qBittorrent version check failed"); - "unknown".to_string() - }; - Ok(ClientStatus { - name: "qBittorrent".to_string(), - protocol: DownloadProtocol::Torrent, - version, - is_connected: true, - }) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/rtbit.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/rtbit.rs deleted file mode 100644 index 14cafbd9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/rtbit.rs +++ /dev/null @@ -1,2 +0,0 @@ -// rtbit download client stub — will wrap the embedded librtbit session -// to implement the DownloadClient trait for native torrent downloads. diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/sabnzbd.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/sabnzbd.rs deleted file mode 100644 index b8e5f9de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/sabnzbd.rs +++ /dev/null @@ -1,231 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, bail}; -use async_trait::async_trait; -use reqwest::Client; -use serde::Deserialize; -use serde_json::Value; - -use crate::client::{ - ClientStatus, DownloadClient, DownloadItem, DownloadItemStatus, DownloadProtocol, GrabRequest, -}; - -/// SABnzbd API client. -pub struct SabnzbdClient { - base_url: String, - api_key: String, - http: Client, -} - -impl SabnzbdClient { - pub fn new(base_url: impl Into, api_key: impl Into) -> Self { - Self { - base_url: base_url.into().trim_end_matches('/').to_string(), - api_key: api_key.into(), - http: Client::new(), - } - } - - /// Build a URL with the standard SABnzbd query params. - fn api_url(&self, mode: &str) -> String { - format!( - "{}/api?mode={}&apikey={}&output=json", - self.base_url, mode, self.api_key - ) - } - - async fn api_get(&self, mode: &str, extra: &[(&str, &str)]) -> anyhow::Result { - let mut url = self.api_url(mode); - for (k, v) in extra { - url.push_str(&format!("&{k}={v}")); - } - let resp = self - .http - .get(&url) - .send() - .await - .context("SABnzbd API request failed")?; - if !resp.status().is_success() { - bail!("SABnzbd API returned HTTP {}", resp.status()); - } - resp.json().await.context("failed to parse SABnzbd response") - } -} - -// ── API response shapes ───────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -struct SabQueueSlot { - nzo_id: String, - filename: String, - status: String, - #[serde(deserialize_with = "deserialize_sab_size")] - mb: f64, - #[serde(deserialize_with = "deserialize_sab_size")] - mbleft: f64, - #[serde(default)] - cat: Option, - #[serde(default)] - storage: Option, -} - -#[derive(Debug, Deserialize)] -struct SabHistorySlot { - nzo_id: String, - name: String, - status: String, - bytes: u64, - #[serde(default)] - category: Option, - #[serde(default)] - storage: Option, -} - -/// SABnzbd sometimes returns sizes as strings, sometimes as numbers. -fn deserialize_sab_size<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let v: Value = Deserialize::deserialize(d)?; - match v { - Value::Number(n) => Ok(n.as_f64().unwrap_or(0.0)), - Value::String(s) => s.parse::().map_err(serde::de::Error::custom), - _ => Ok(0.0), - } -} - -fn mb_to_bytes(mb: f64) -> u64 { - (mb * 1_048_576.0) as u64 -} - -fn map_sab_status(status: &str) -> DownloadItemStatus { - match status { - "Downloading" => DownloadItemStatus::Downloading, - "Paused" => DownloadItemStatus::Paused, - "Queued" | "Idle" | "Fetching" | "Propagating" => DownloadItemStatus::Queued, - "Extracting" | "Repairing" | "Verifying" | "Running" => DownloadItemStatus::Extracting, - "Completed" => DownloadItemStatus::Completed, - "Failed" => DownloadItemStatus::Failed, - _ => DownloadItemStatus::Queued, - } -} - -fn map_sab_history_status(status: &str) -> DownloadItemStatus { - match status { - "Completed" => DownloadItemStatus::Completed, - "Failed" => DownloadItemStatus::Failed, - "Extracting" | "Repairing" | "Verifying" | "Running" => DownloadItemStatus::Extracting, - _ => DownloadItemStatus::Completed, - } -} - -// ── DownloadClient impl ───────────────────────────────────────────────────── - -#[async_trait] -impl DownloadClient for SabnzbdClient { - fn name(&self) -> &str { - "SABnzbd" - } - - fn protocol(&self) -> DownloadProtocol { - DownloadProtocol::Usenet - } - - async fn add(&self, request: &GrabRequest) -> anyhow::Result { - let mut extra: Vec<(&str, &str)> = vec![("name", &request.download_url)]; - let cat_val; - if let Some(cat) = &request.category { - cat_val = cat.clone(); - extra.push(("cat", &cat_val)); - } - let result = self.api_get("addurl", &extra).await?; - let nzo_ids = result["nzo_ids"] - .as_array() - .and_then(|a| a.first()) - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - Ok(nzo_ids) - } - - async fn get_items(&self) -> anyhow::Result> { - let mut items = Vec::new(); - - // Active queue - let queue_resp = self.api_get("queue", &[]).await?; - if let Some(slots) = queue_resp["queue"]["slots"].as_array() { - for slot_val in slots { - if let Ok(slot) = serde_json::from_value::(slot_val.clone()) { - items.push(DownloadItem { - download_id: slot.nzo_id, - title: slot.filename, - status: map_sab_status(&slot.status), - total_size: mb_to_bytes(slot.mb), - remaining_size: mb_to_bytes(slot.mbleft), - output_path: slot.storage.map(PathBuf::from), - category: slot.cat, - can_move_files: true, - can_be_removed: true, - protocol: DownloadProtocol::Usenet, - }); - } - } - } - - // History (completed / failed) - let hist_resp = self.api_get("history", &[]).await?; - if let Some(slots) = hist_resp["history"]["slots"].as_array() { - for slot_val in slots { - if let Ok(slot) = serde_json::from_value::(slot_val.clone()) { - items.push(DownloadItem { - download_id: slot.nzo_id, - title: slot.name, - status: map_sab_history_status(&slot.status), - total_size: slot.bytes, - remaining_size: 0, - output_path: slot.storage.map(PathBuf::from), - category: slot.category, - can_move_files: true, - can_be_removed: true, - protocol: DownloadProtocol::Usenet, - }); - } - } - } - - Ok(items) - } - - async fn remove(&self, id: &str, _delete_data: bool) -> anyhow::Result<()> { - // Try removing from queue first, then history - let _ = self.api_get("queue", &[("name", "delete"), ("value", id)]).await; - let _ = self.api_get("history", &[("name", "delete"), ("value", id)]).await; - Ok(()) - } - - async fn pause(&self, id: &str) -> anyhow::Result<()> { - self.api_get("pause", &[("value", id)]).await?; - Ok(()) - } - - async fn resume(&self, id: &str) -> anyhow::Result<()> { - self.api_get("resume", &[("value", id)]).await?; - Ok(()) - } - - async fn test(&self) -> anyhow::Result<()> { - let result = self.api_get("version", &[]).await?; - if result["version"].is_null() { - bail!("SABnzbd test failed — no version in response"); - } - Ok(()) - } - - async fn status(&self) -> anyhow::Result { - let result = self.api_get("version", &[]).await?; - let version = result["version"].as_str().unwrap_or("unknown").to_string(); - Ok(ClientStatus { - name: "SABnzbd".to_string(), - protocol: DownloadProtocol::Usenet, - version, - is_connected: true, - }) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/transmission.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/transmission.rs deleted file mode 100644 index 8dcea5bb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-download/src/transmission.rs +++ /dev/null @@ -1,215 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Context, bail}; -use async_trait::async_trait; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use tokio::sync::RwLock; -use tracing::debug; - -use crate::client::{ - ClientStatus, DownloadClient, DownloadItem, DownloadItemStatus, DownloadProtocol, GrabRequest, -}; - -/// Transmission RPC client. -pub struct TransmissionClient { - base_url: String, - username: Option, - password: Option, - session_id: RwLock>, - http: Client, -} - -impl TransmissionClient { - pub fn new( - base_url: impl Into, - username: Option, - password: Option, - ) -> Self { - Self { - base_url: base_url.into().trim_end_matches('/').to_string(), - username, - password, - session_id: RwLock::new(None), - http: Client::new(), - } - } - - fn rpc_url(&self) -> String { - format!("{}/transmission/rpc", self.base_url) - } - - /// Send a JSON-RPC request, automatically handling 409 session-id refresh. - async fn rpc(&self, method: &str, arguments: Option) -> anyhow::Result { - let body = RpcRequest { - method: method.to_string(), - arguments, - }; - - // First attempt - let resp = self.send_rpc(&body).await?; - if resp.status() == reqwest::StatusCode::CONFLICT { - // Extract session id from header and retry - if let Some(sid) = resp.headers().get("X-Transmission-Session-Id") { - let sid = sid.to_str().unwrap_or_default().to_string(); - debug!("Transmission 409 — new session id acquired"); - *self.session_id.write().await = Some(sid); - } - let resp = self.send_rpc(&body).await?; - return self.parse_response(resp).await; - } - self.parse_response(resp).await - } - - async fn send_rpc(&self, body: &RpcRequest) -> anyhow::Result { - let mut req = self.http.post(self.rpc_url()).json(body); - - if let Some(sid) = self.session_id.read().await.as_deref() { - req = req.header("X-Transmission-Session-Id", sid); - } - if let (Some(u), Some(p)) = (&self.username, &self.password) { - req = req.basic_auth(u, Some(p)); - } - - req.send().await.context("Transmission RPC request failed") - } - - async fn parse_response(&self, resp: reqwest::Response) -> anyhow::Result { - let status = resp.status(); - let body: Value = resp.json().await.context("failed to parse Transmission response")?; - let result_str = body["result"].as_str().unwrap_or(""); - if !status.is_success() || result_str != "success" { - bail!( - "Transmission RPC error (HTTP {status}): {}", - body["result"].as_str().unwrap_or("unknown") - ); - } - Ok(body["arguments"].clone()) - } -} - -// ── Wire types ────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -struct RpcRequest { - method: String, - #[serde(skip_serializing_if = "Option::is_none")] - arguments: Option, -} - -#[derive(Debug, Deserialize)] -struct TransTorrent { - #[serde(rename = "hashString")] - hash_string: String, - name: String, - status: i64, - #[serde(rename = "totalSize")] - total_size: u64, - #[serde(rename = "leftUntilDone")] - left_until_done: u64, - #[serde(rename = "downloadDir")] - download_dir: Option, -} - -impl TransTorrent { - fn to_item(&self) -> DownloadItem { - DownloadItem { - download_id: self.hash_string.clone(), - title: self.name.clone(), - status: map_transmission_status(self.status), - total_size: self.total_size, - remaining_size: self.left_until_done, - output_path: self.download_dir.as_ref().map(PathBuf::from), - category: None, - can_move_files: true, - can_be_removed: true, - protocol: DownloadProtocol::Torrent, - } - } -} - -/// Transmission status codes: -/// 0=stopped, 1=check-wait, 2=check, 3=dl-wait, 4=downloading, 5=seed-wait, 6=seeding -fn map_transmission_status(status: i64) -> DownloadItemStatus { - match status { - 0 => DownloadItemStatus::Paused, - 1 | 3 => DownloadItemStatus::Queued, - 2 => DownloadItemStatus::Verifying, - 4 => DownloadItemStatus::Downloading, - 5 | 6 => DownloadItemStatus::Seeding, - _ => DownloadItemStatus::Queued, - } -} - -// ── DownloadClient impl ───────────────────────────────────────────────────── - -#[async_trait] -impl DownloadClient for TransmissionClient { - fn name(&self) -> &str { - "Transmission" - } - - fn protocol(&self) -> DownloadProtocol { - DownloadProtocol::Torrent - } - - async fn add(&self, request: &GrabRequest) -> anyhow::Result { - let args = json!({ "filename": request.download_url }); - let result = self.rpc("torrent-add", Some(args)).await?; - // The response contains either torrent-added or torrent-duplicate - let hash = result["torrent-added"]["hashString"] - .as_str() - .or_else(|| result["torrent-duplicate"]["hashString"].as_str()) - .unwrap_or("unknown") - .to_string(); - Ok(hash) - } - - async fn get_items(&self) -> anyhow::Result> { - let args = json!({ - "fields": ["hashString", "name", "status", "totalSize", "leftUntilDone", "downloadDir"] - }); - let result = self.rpc("torrent-get", Some(args)).await?; - let torrents: Vec = - serde_json::from_value(result["torrents"].clone()).unwrap_or_default(); - Ok(torrents.iter().map(TransTorrent::to_item).collect()) - } - - async fn remove(&self, id: &str, delete_data: bool) -> anyhow::Result<()> { - let args = json!({ - "ids": [id], - "delete-local-data": delete_data, - }); - self.rpc("torrent-remove", Some(args)).await?; - Ok(()) - } - - async fn pause(&self, id: &str) -> anyhow::Result<()> { - let args = json!({ "ids": [id] }); - self.rpc("torrent-stop", Some(args)).await?; - Ok(()) - } - - async fn resume(&self, id: &str) -> anyhow::Result<()> { - let args = json!({ "ids": [id] }); - self.rpc("torrent-start", Some(args)).await?; - Ok(()) - } - - async fn test(&self) -> anyhow::Result<()> { - self.rpc("session-get", None).await?; - Ok(()) - } - - async fn status(&self) -> anyhow::Result { - let result = self.rpc("session-get", None).await?; - let version = result["version"].as_str().unwrap_or("unknown").to_string(); - Ok(ClientStatus { - name: "Transmission".to_string(), - protocol: DownloadProtocol::Torrent, - version, - is_connected: true, - }) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/Cargo.toml deleted file mode 100644 index 7c094411..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "stackarr-import" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-media = { workspace = true } -stackarr-parser = { workspace = true } -stackarr-quality = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -regex = { workspace = true } -once_cell = "1" -walkdir = "2" - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/src/lib.rs deleted file mode 100644 index 81a775b7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/src/lib.rs +++ /dev/null @@ -1,1459 +0,0 @@ -pub mod naming; - -use std::path::{Path, PathBuf}; - -use anyhow::Result; -use serde::Serialize; -use sqlx::PgPool; - -use naming::{build_episode_filename, build_movie_filename, build_season_folder, sanitize_filename}; - -// ── Types ─────────────────────────────────────────────────────────────────── - -/// A file discovered on disk during import scanning. -#[derive(Debug, Clone)] -pub struct LocalFile { - pub path: PathBuf, - pub size: u64, - pub extension: String, -} - -/// Result of processing a completed download or folder scan. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportResult { - pub imported_files: Vec, - pub skipped_files: Vec, - pub errors: Vec, -} - -/// A single file that was successfully imported. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportedFile { - pub source_path: String, - pub dest_path: String, - pub media_file_id: i64, - pub quality: String, - pub size: i64, -} - -/// Result of a disk scan operation. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DiskScanResult { - pub files_found: usize, - pub files_matched: usize, - pub files_unmatched: usize, - pub files_already_tracked: usize, - pub unmatched_files: Vec, -} - -/// Context needed to process a completed download. -pub struct ImportContext { - pub pool: PgPool, - /// Download ID from the queue record. - pub download_id: String, - /// Path where the download client placed the files. - pub output_path: PathBuf, - /// `"series"` or `"movie"` from the queue record. - pub media_type: String, - /// The series_id or movie_id from the queue record. - pub media_id: i64, - /// The episode_id from the queue record (for TV). - pub episode_id: Option, -} - -// ── Naming config loaded from DB ──────────────────────────────────────────── - -struct NamingConfig { - rename_files: bool, - standard_format: Option, - season_folder_format: Option, - movie_format: Option, - colon_replacement: String, -} - -async fn load_naming_config(pool: &PgPool, media_type: &str) -> Result { - let row: Option<(bool, Option, Option, Option, String)> = - sqlx::query_as( - "SELECT rename_files, standard_format, season_folder_format, movie_format, colon_replacement \ - FROM naming_config WHERE media_type = $1", - ) - .bind(media_type) - .fetch_optional(pool) - .await?; - - match row { - Some((rename_files, standard_format, season_folder_format, movie_format, colon_replacement)) => { - Ok(NamingConfig { - rename_files, - standard_format, - season_folder_format, - movie_format, - colon_replacement, - }) - } - None => { - // Sensible defaults if no config exists - Ok(NamingConfig { - rename_files: true, - standard_format: Some( - "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]" - .to_string(), - ), - season_folder_format: Some("Season {season:00}".to_string()), - movie_format: Some( - "{Movie Title} ({Release Year}) [{Quality Title}]".to_string(), - ), - colon_replacement: "smart".to_string(), - }) - } - } -} - -// ── Media extensions ───────────────────────────────────────────────────────── - -fn is_media_extension(ext: &str) -> bool { - matches!( - ext, - "mkv" | "mp4" | "avi" | "wmv" | "ts" | "m4v" | "flv" | "mov" | "webm" - ) -} - -/// Minimum size (in bytes) for a file to not be considered a sample. -const SAMPLE_SIZE_THRESHOLD: u64 = 50 * 1024 * 1024; // 50 MB - -/// Returns true if the file is likely a sample (small + "sample" in name). -fn is_sample(path: &Path, size: u64) -> bool { - if size >= SAMPLE_SIZE_THRESHOLD { - return false; - } - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_lowercase(); - name.contains("sample") -} - -// ── Import pipeline ───────────────────────────────────────────────────────── - -/// Process a completed download: scan for media files, match to the library -/// item identified in the queue, rename with naming config tokens, move into -/// the library path, and update the database. -pub async fn process_completed_download(ctx: ImportContext) -> Result { - tracing::info!( - download_id = %ctx.download_id, - path = %ctx.output_path.display(), - media_type = %ctx.media_type, - media_id = ctx.media_id, - "processing completed download" - ); - - let mut result = ImportResult { - imported_files: Vec::new(), - skipped_files: Vec::new(), - errors: Vec::new(), - }; - - // 1. Scan output_path for media files - let files = scan_folder_for_media(&ctx.output_path)?; - if files.is_empty() { - result.errors.push(format!( - "no media files found in {}", - ctx.output_path.display() - )); - return Ok(result); - } - - tracing::info!(count = files.len(), "discovered media files"); - - // 2. Filter out samples - let media_files: Vec<_> = files - .into_iter() - .filter(|f| { - if is_sample(&f.path, f.size) { - tracing::debug!(path = %f.path.display(), "skipping sample file"); - result.skipped_files.push(f.path.display().to_string()); - false - } else { - true - } - }) - .collect(); - - // 3. Process each media file - for file in &media_files { - match process_single_file(&ctx, file, &mut result).await { - Ok(()) => {} - Err(e) => { - let msg = format!("error importing {}: {e}", file.path.display()); - tracing::error!("{msg}"); - result.errors.push(msg); - } - } - } - - tracing::info!( - imported = result.imported_files.len(), - skipped = result.skipped_files.len(), - errors = result.errors.len(), - "import complete for download {}", - ctx.download_id - ); - - Ok(result) -} - -/// Process a single media file from a completed download. -async fn process_single_file( - ctx: &ImportContext, - file: &LocalFile, - result: &mut ImportResult, -) -> Result<()> { - // Parse the filename for quality/episode info - let filename = file - .path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or(""); - let parsed = stackarr_parser::parse_release(filename); - - match ctx.media_type.as_str() { - "series" | "tv" => { - import_series_file(ctx, file, &parsed, result).await?; - } - "movie" => { - import_movie_file(ctx, file, &parsed, result).await?; - } - other => { - result - .errors - .push(format!("unknown media_type '{other}' for {filename}")); - } - } - - Ok(()) -} - -/// Import a media file for a TV series episode. -async fn import_series_file( - ctx: &ImportContext, - file: &LocalFile, - parsed: &stackarr_parser::ParsedRelease, - result: &mut ImportResult, -) -> Result<()> { - let pool = &ctx.pool; - - // Load series from DB - let series_row: Option<(i64, String, String)> = sqlx::query_as( - "SELECT id, title, path FROM series WHERE id = $1", - ) - .bind(ctx.media_id) - .fetch_optional(pool) - .await?; - - let (series_id, series_title, series_path) = match series_row { - Some(row) => row, - None => { - anyhow::bail!("series id={} not found in DB", ctx.media_id); - } - }; - - // Determine season + episode numbers from the parsed release or the queue - let season = parsed.episode_info.season_number; - let episodes = &parsed.episode_info.episode_numbers; - - // If the queue has a specific episode_id, load that episode - let episode_row: Option<(i64, i32, i32, Option)> = if let Some(ep_id) = ctx.episode_id - { - sqlx::query_as( - "SELECT id, season_number, episode_number, title FROM episodes WHERE id = $1", - ) - .bind(ep_id) - .fetch_optional(pool) - .await? - } else if let (Some(s), Some(&e)) = (season, episodes.first()) { - // Fall back to matching by season/episode from parsed name - sqlx::query_as( - "SELECT id, season_number, episode_number, title FROM episodes \ - WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", - ) - .bind(series_id) - .bind(s) - .bind(e) - .fetch_optional(pool) - .await? - } else { - None - }; - - let (episode_id, season_num, episode_num, episode_title) = match episode_row { - Some(row) => row, - None => { - result.skipped_files.push(format!( - "{}: could not match to episode", - file.path.display() - )); - return Ok(()); - } - }; - - // Load naming config - let naming = load_naming_config(pool, "series").await?; - let ext = &file.extension; - - // Build the destination path - let dest_path = if naming.rename_files { - let format = naming - .standard_format - .as_deref() - .unwrap_or("{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]"); - - let episode_filename = build_episode_filename( - format, - &series_title, - season_num, - episode_num, - episode_title.as_deref(), - &parsed.quality.quality, - parsed.release_group.as_deref(), - parsed.episode_info.absolute_episode_numbers.first().copied(), - ); - let safe_filename = sanitize_filename(&episode_filename, &naming.colon_replacement); - - let season_folder_fmt = naming - .season_folder_format - .as_deref() - .unwrap_or("Season {season:00}"); - let season_folder = build_season_folder(season_folder_fmt, season_num); - let safe_season = sanitize_filename(&season_folder, &naming.colon_replacement); - - PathBuf::from(&series_path) - .join(&safe_season) - .join(format!("{safe_filename}.{ext}")) - } else { - // Keep original filename, just place in series/season folder - let original_name = file - .path - .file_name() - .unwrap_or_default() - .to_string_lossy(); - let season_folder_fmt = naming - .season_folder_format - .as_deref() - .unwrap_or("Season {season:00}"); - let season_folder = build_season_folder(season_folder_fmt, season_num); - let safe_season = sanitize_filename(&season_folder, &naming.colon_replacement); - - PathBuf::from(&series_path) - .join(&safe_season) - .join(original_name.as_ref()) - }; - - // Create parent directories - if let Some(parent) = dest_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Move file: try rename first (same filesystem), fall back to copy+remove - move_file(&file.path, &dest_path).await?; - - // Build relative path (relative to series root) - let relative_path = dest_path - .strip_prefix(&series_path) - .unwrap_or(&dest_path) - .display() - .to_string(); - - // Insert media_files record - let quality_json = serde_json::to_value(&parsed.quality)?; - let languages_json = serde_json::to_value(&parsed.languages)?; - let size = file.size as i64; - - let media_file_row: (i64,) = sqlx::query_as( - "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition) \ - VALUES ('series', $1, $2, $3, $4, $5, $6, $7, $8) \ - RETURNING id", - ) - .bind(&relative_path) - .bind(size) - .bind(&quality_json) - .bind(&languages_json) - .bind(file.path.file_name().and_then(|f| f.to_str()).unwrap_or("")) - .bind(&parsed.release_group) - .bind(&parsed.release_hash) - .bind(&parsed.edition) - .fetch_one(pool) - .await?; - - let media_file_id = media_file_row.0; - - // Link episode to media file - sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(episode_id) - .bind(media_file_id) - .execute(pool) - .await?; - - // Update the episode's file pointer - sqlx::query("UPDATE episodes SET episode_file_id = $1 WHERE id = $2") - .bind(media_file_id) - .bind(episode_id) - .execute(pool) - .await?; - - // Also link any additional episodes (multi-episode files) - if episodes.len() > 1 { - for &ep_num in episodes.iter().skip(1) { - if let Some(s) = season { - let extra_ep: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM episodes WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", - ) - .bind(series_id) - .bind(s) - .bind(ep_num) - .fetch_optional(pool) - .await?; - - if let Some((extra_id,)) = extra_ep { - sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(extra_id) - .bind(media_file_id) - .execute(pool) - .await?; - - sqlx::query( - "UPDATE episodes SET episode_file_id = $1 WHERE id = $2 AND episode_file_id IS NULL", - ) - .bind(media_file_id) - .bind(extra_id) - .execute(pool) - .await?; - } - } - } - } - - // Insert history record - sqlx::query( - "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, languages, source_title, download_id) \ - VALUES ('series', $1, $2, 'imported', $3, $4, $5, $6)", - ) - .bind(series_id) - .bind(episode_id) - .bind(&quality_json) - .bind(&languages_json) - .bind(file.path.file_name().and_then(|f| f.to_str()).unwrap_or("")) - .bind(&ctx.download_id) - .execute(pool) - .await?; - - let quality_str = format!("{:?}", parsed.quality.quality); - result.imported_files.push(ImportedFile { - source_path: file.path.display().to_string(), - dest_path: dest_path.display().to_string(), - media_file_id, - quality: quality_str, - size, - }); - - tracing::info!( - source = %file.path.display(), - dest = %dest_path.display(), - media_file_id, - "imported series episode" - ); - - Ok(()) -} - -/// Import a media file for a movie. -async fn import_movie_file( - ctx: &ImportContext, - file: &LocalFile, - parsed: &stackarr_parser::ParsedRelease, - result: &mut ImportResult, -) -> Result<()> { - let pool = &ctx.pool; - - // Load movie from DB - let movie_row: Option<(i64, String, String, Option)> = sqlx::query_as( - "SELECT id, title, path, year FROM movies WHERE id = $1", - ) - .bind(ctx.media_id) - .fetch_optional(pool) - .await?; - - let (movie_id, movie_title, movie_path, movie_year) = match movie_row { - Some(row) => row, - None => { - anyhow::bail!("movie id={} not found in DB", ctx.media_id); - } - }; - - // Load naming config - let naming = load_naming_config(pool, "movie").await?; - let ext = &file.extension; - - // Build the destination path - let dest_path = if naming.rename_files { - let format = naming - .movie_format - .as_deref() - .unwrap_or("{Movie Title} ({Release Year}) [{Quality Title}]"); - - let movie_filename = build_movie_filename( - format, - &movie_title, - movie_year, - &parsed.quality.quality, - parsed.edition.as_deref(), - parsed.release_group.as_deref(), - ); - let safe_filename = sanitize_filename(&movie_filename, &naming.colon_replacement); - - PathBuf::from(&movie_path).join(format!("{safe_filename}.{ext}")) - } else { - let original_name = file - .path - .file_name() - .unwrap_or_default() - .to_string_lossy(); - PathBuf::from(&movie_path).join(original_name.as_ref()) - }; - - // Create parent directories - if let Some(parent) = dest_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Move file - move_file(&file.path, &dest_path).await?; - - // Build relative path (relative to movie root) - let relative_path = dest_path - .strip_prefix(&movie_path) - .unwrap_or(&dest_path) - .display() - .to_string(); - - // Insert media_files record - let quality_json = serde_json::to_value(&parsed.quality)?; - let languages_json = serde_json::to_value(&parsed.languages)?; - let size = file.size as i64; - - let media_file_row: (i64,) = sqlx::query_as( - "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition) \ - VALUES ('movie', $1, $2, $3, $4, $5, $6, $7, $8) \ - RETURNING id", - ) - .bind(&relative_path) - .bind(size) - .bind(&quality_json) - .bind(&languages_json) - .bind(file.path.file_name().and_then(|f| f.to_str()).unwrap_or("")) - .bind(&parsed.release_group) - .bind(&parsed.release_hash) - .bind(&parsed.edition) - .fetch_one(pool) - .await?; - - let media_file_id = media_file_row.0; - - // Link to movie - sqlx::query("UPDATE movies SET movie_file_id = $1 WHERE id = $2") - .bind(media_file_id) - .bind(movie_id) - .execute(pool) - .await?; - - // Insert history record - sqlx::query( - "INSERT INTO history (media_type, media_id, event_type, quality, languages, source_title, download_id) \ - VALUES ('movie', $1, 'imported', $2, $3, $4, $5)", - ) - .bind(movie_id) - .bind(&quality_json) - .bind(&languages_json) - .bind(file.path.file_name().and_then(|f| f.to_str()).unwrap_or("")) - .bind(&ctx.download_id) - .execute(pool) - .await?; - - let quality_str = format!("{:?}", parsed.quality.quality); - result.imported_files.push(ImportedFile { - source_path: file.path.display().to_string(), - dest_path: dest_path.display().to_string(), - media_file_id, - quality: quality_str, - size, - }); - - tracing::info!( - source = %file.path.display(), - dest = %dest_path.display(), - media_file_id, - "imported movie file" - ); - - Ok(()) -} - -// ── File operations ───────────────────────────────────────────────────────── - -/// Move a file from `src` to `dest`. Tries `tokio::fs::rename` first (fast, -/// same-filesystem). On cross-device errors, falls back to copy + remove. -async fn move_file(src: &Path, dest: &Path) -> Result<()> { - match tokio::fs::rename(src, dest).await { - Ok(()) => { - tracing::debug!(src = %src.display(), dest = %dest.display(), "renamed file"); - Ok(()) - } - Err(e) if e.raw_os_error() == Some(18 /* EXDEV */) => { - // Cross-device link — fall back to copy + remove - tracing::debug!( - src = %src.display(), - dest = %dest.display(), - "cross-device move, falling back to copy+remove" - ); - tokio::fs::copy(src, dest).await?; - tokio::fs::remove_file(src).await?; - Ok(()) - } - Err(e) => Err(e.into()), - } -} - -// ── Folder scanning ───────────────────────────────────────────────────────── - -/// Recursively scan a folder for media files, returning all discovered files. -fn scan_folder_for_media(folder: &Path) -> Result> { - let mut files = Vec::new(); - - if !folder.exists() { - anyhow::bail!("folder does not exist: {}", folder.display()); - } - - // Handle case where output_path is a single file - if folder.is_file() { - let ext = folder - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - if is_media_extension(&ext) { - let size = std::fs::metadata(folder).map(|m| m.len()).unwrap_or(0); - files.push(LocalFile { - path: folder.to_path_buf(), - size, - extension: ext, - }); - } - return Ok(files); - } - - for entry in walkdir::WalkDir::new(folder) - .into_iter() - .filter_map(|e| e.ok()) - { - if !entry.file_type().is_file() { - continue; - } - let path = entry.path().to_path_buf(); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - if is_media_extension(&ext) { - let size = entry.metadata().map(|m| m.len()).unwrap_or(0); - files.push(LocalFile { - path, - size, - extension: ext, - }); - } - } - - Ok(files) -} - -// ── Import service (kept for backward compat) ─────────────────────────────── - -#[derive(Clone)] -pub struct ImportService { - #[allow(dead_code)] - pool: PgPool, -} - -impl ImportService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Process a completed download via the new import pipeline. - pub async fn process_completed_download( - &self, - download_folder: &Path, - download_id: &str, - ) -> Result { - // Legacy shim: without full context we cannot do the complete pipeline, - // so just do a file scan and return counts. - tracing::info!( - download_id, - path = %download_folder.display(), - "processing completed download (legacy path)" - ); - - let files = scan_folder_for_media(download_folder)?; - let _media_count = files - .iter() - .filter(|f| !is_sample(&f.path, f.size)) - .count(); - - Ok(ImportResult { - imported_files: Vec::new(), - skipped_files: Vec::new(), - errors: if files.is_empty() { - vec![format!( - "no media files found in {}", - download_folder.display() - )] - } else { - Vec::new() - }, - }) - } - - /// Recursively scan a folder for media files. - pub fn scan_folder(&self, folder: &Path) -> Result> { - scan_folder_for_media(folder) - } -} - -// ── Disk scan ──────────────────────────────────────────────────────────────── - -/// Scan a media library folder for media files on disk, matching them to series/movies -/// already in the database. Creates `media_file` records for matched files. -/// -/// `media_type` should be `"series"` or `"movie"`. -pub async fn disk_scan( - pool: &PgPool, - root_path: &Path, - media_type: &str, -) -> Result { - tracing::info!( - path = %root_path.display(), - media_type, - "starting disk scan" - ); - - if !root_path.exists() { - anyhow::bail!("media library folder does not exist: {}", root_path.display()); - } - - match media_type { - "series" | "tv" => scan_series(pool, root_path).await, - "movie" => scan_movies(pool, root_path).await, - other => anyhow::bail!("unknown media_type: {other}"), - } -} - -/// Scan for series: expects `{root}/{Series Name}/Season XX/file.mkv` -async fn scan_series(pool: &PgPool, root_path: &Path) -> Result { - let mut result = DiskScanResult { - files_found: 0, - files_matched: 0, - files_unmatched: 0, - files_already_tracked: 0, - unmatched_files: Vec::new(), - }; - - // Walk for media files - for entry in walkdir::WalkDir::new(root_path) - .into_iter() - .filter_map(|e| e.ok()) - { - if !entry.file_type().is_file() { - continue; - } - let path = entry.path(); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - if !is_media_extension(&ext) { - continue; - } - - result.files_found += 1; - let size = entry.metadata().map(|m| m.len()).unwrap_or(0) as i64; - - // Extract series name from first-level directory under root - let relative = match path.strip_prefix(root_path) { - Ok(r) => r, - Err(_) => { - result.files_unmatched += 1; - result.unmatched_files.push(path.display().to_string()); - continue; - } - }; - - let components: Vec<_> = relative.components().collect(); - if components.is_empty() { - result.files_unmatched += 1; - result.unmatched_files.push(path.display().to_string()); - continue; - } - - // First component is the series directory name - let series_dir_name = components[0].as_os_str().to_string_lossy().to_string(); - let clean_dir = stackarr_parser::clean_title(&series_dir_name); - - // Match to series in DB by clean_title or by folder name in path - let series_row: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM series WHERE clean_title = $1 OR RTRIM(path, '/') LIKE '%/' || $2", - ) - .bind(&clean_dir) - .bind(&series_dir_name) - .fetch_optional(pool) - .await?; - - let series_id = match series_row { - Some((id,)) => id, - None => { - tracing::debug!( - series_dir = %series_dir_name, - clean_title = %clean_dir, - "no matching series found in DB" - ); - result.files_unmatched += 1; - result.unmatched_files.push(path.display().to_string()); - continue; - } - }; - - // Check if this file is already tracked - let relative_path_str = relative.display().to_string(); - let existing: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM media_files WHERE relative_path = $1 AND media_type = 'series'", - ) - .bind(&relative_path_str) - .fetch_optional(pool) - .await?; - - if existing.is_some() { - result.files_already_tracked += 1; - continue; - } - - // Parse the filename for quality/episode info - let filename = path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or(""); - let parsed = stackarr_parser::parse_release(filename); - let quality_json = serde_json::to_value(&parsed.quality)?; - let languages_json = serde_json::to_value(&parsed.languages)?; - - // Insert media_file record - let media_file_row: (i64,) = sqlx::query_as( - "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition) - VALUES ('series', $1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", - ) - .bind(&relative_path_str) - .bind(size) - .bind(&quality_json) - .bind(&languages_json) - .bind(filename) - .bind(&parsed.release_group) - .bind(&parsed.release_hash) - .bind(&parsed.edition) - .fetch_one(pool) - .await?; - - let media_file_id = media_file_row.0; - - // Try to match to specific episode - let season = parsed.episode_info.season_number; - let episodes = &parsed.episode_info.episode_numbers; - - if let Some(season_num) = season { - for &ep_num in episodes { - // Find the episode - let episode_row: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM episodes WHERE series_id = $1 AND season_number = $2 AND episode_number = $3", - ) - .bind(series_id) - .bind(season_num) - .bind(ep_num) - .fetch_optional(pool) - .await?; - - if let Some((episode_id,)) = episode_row { - // Link episode to media file via episode_files join table - sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(episode_id) - .bind(media_file_id) - .execute(pool) - .await?; - - // Also update the episode's episode_file_id pointer - sqlx::query( - "UPDATE episodes SET episode_file_id = $1 WHERE id = $2 AND episode_file_id IS NULL", - ) - .bind(media_file_id) - .bind(episode_id) - .execute(pool) - .await?; - } - } - } - - result.files_matched += 1; - tracing::debug!( - file = %relative_path_str, - series_id, - "matched series file" - ); - } - - tracing::info!( - found = result.files_found, - matched = result.files_matched, - unmatched = result.files_unmatched, - already_tracked = result.files_already_tracked, - "series disk scan complete" - ); - - Ok(result) -} - -/// Scan for movies: expects `{root}/{Movie Name (Year)}/file.mkv` -async fn scan_movies(pool: &PgPool, root_path: &Path) -> Result { - let mut result = DiskScanResult { - files_found: 0, - files_matched: 0, - files_unmatched: 0, - files_already_tracked: 0, - unmatched_files: Vec::new(), - }; - - for entry in walkdir::WalkDir::new(root_path) - .into_iter() - .filter_map(|e| e.ok()) - { - if !entry.file_type().is_file() { - continue; - } - let path = entry.path(); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - if !is_media_extension(&ext) { - continue; - } - - result.files_found += 1; - let size = entry.metadata().map(|m| m.len()).unwrap_or(0) as i64; - - // Extract movie name from first-level directory under root - let relative = match path.strip_prefix(root_path) { - Ok(r) => r, - Err(_) => { - result.files_unmatched += 1; - result.unmatched_files.push(path.display().to_string()); - continue; - } - }; - - let components: Vec<_> = relative.components().collect(); - if components.is_empty() { - result.files_unmatched += 1; - result.unmatched_files.push(path.display().to_string()); - continue; - } - - // First component is the movie directory name - let movie_dir_name = components[0].as_os_str().to_string_lossy().to_string(); - let clean_dir = stackarr_parser::clean_title(&movie_dir_name); - - // Match to movie in DB by clean_title or by folder name in path - let movie_row: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM movies WHERE clean_title = $1 OR RTRIM(path, '/') LIKE '%/' || $2", - ) - .bind(&clean_dir) - .bind(&movie_dir_name) - .fetch_optional(pool) - .await?; - - let movie_id = match movie_row { - Some((id,)) => id, - None => { - tracing::debug!( - movie_dir = %movie_dir_name, - clean_title = %clean_dir, - "no matching movie found in DB" - ); - result.files_unmatched += 1; - result.unmatched_files.push(path.display().to_string()); - continue; - } - }; - - // Check if this file is already tracked - let relative_path_str = relative.display().to_string(); - let existing: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM media_files WHERE relative_path = $1 AND media_type = 'movie'", - ) - .bind(&relative_path_str) - .fetch_optional(pool) - .await?; - - if existing.is_some() { - result.files_already_tracked += 1; - continue; - } - - // Parse the filename for quality info - let filename = path - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or(""); - let parsed = stackarr_parser::parse_release(filename); - let quality_json = serde_json::to_value(&parsed.quality)?; - let languages_json = serde_json::to_value(&parsed.languages)?; - - // Insert media_file record - let media_file_row: (i64,) = sqlx::query_as( - "INSERT INTO media_files (media_type, relative_path, size, quality, languages, scene_name, release_group, release_hash, edition) - VALUES ('movie', $1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", - ) - .bind(&relative_path_str) - .bind(size) - .bind(&quality_json) - .bind(&languages_json) - .bind(filename) - .bind(&parsed.release_group) - .bind(&parsed.release_hash) - .bind(&parsed.edition) - .fetch_one(pool) - .await?; - - let media_file_id = media_file_row.0; - - // Link to movie - sqlx::query( - "UPDATE movies SET movie_file_id = $1 WHERE id = $2 AND movie_file_id IS NULL", - ) - .bind(media_file_id) - .bind(movie_id) - .execute(pool) - .await?; - - result.files_matched += 1; - tracing::debug!( - file = %relative_path_str, - movie_id, - "matched movie file" - ); - } - - tracing::info!( - found = result.files_found, - matched = result.files_matched, - unmatched = result.files_unmatched, - already_tracked = result.files_already_tracked, - "movie disk scan complete" - ); - - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - #[test] - fn test_is_media_extension_valid() { - assert!(is_media_extension("mkv")); - assert!(is_media_extension("mp4")); - assert!(is_media_extension("avi")); - assert!(is_media_extension("wmv")); - assert!(is_media_extension("ts")); - assert!(is_media_extension("m4v")); - assert!(is_media_extension("flv")); - assert!(is_media_extension("mov")); - assert!(is_media_extension("webm")); - } - - #[test] - fn test_is_media_extension_invalid() { - assert!(!is_media_extension("nfo")); - assert!(!is_media_extension("txt")); - assert!(!is_media_extension("jpg")); - assert!(!is_media_extension("srt")); - assert!(!is_media_extension("nzb")); - assert!(!is_media_extension("")); - } - - #[test] - fn test_is_sample_small_with_keyword() { - let path = Path::new("/downloads/Movie.2024/sample.mkv"); - assert!(is_sample(path, 10 * 1024 * 1024)); // 10 MB - } - - #[test] - fn test_is_sample_large_file() { - let path = Path::new("/downloads/Movie.2024/sample.mkv"); - assert!(!is_sample(path, 100 * 1024 * 1024)); // 100 MB — too large to be sample - } - - #[test] - fn test_is_sample_no_keyword() { - let path = Path::new("/downloads/Movie.2024/Movie.2024.720p.mkv"); - assert!(!is_sample(path, 10 * 1024 * 1024)); // small but no "sample" in name - } - - #[test] - fn test_is_sample_case_insensitive() { - let path = Path::new("/downloads/Movie.Sample.mkv"); - assert!(is_sample(path, 5 * 1024 * 1024)); - } - - #[test] - fn test_scan_folder_finds_media() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("movie.mkv"), "fake video content").unwrap(); - fs::write(dir.path().join("subtitle.srt"), "subtitle").unwrap(); - fs::write(dir.path().join("info.nfo"), "nfo").unwrap(); - - let files = scan_folder_for_media(dir.path()).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].extension, "mkv"); - } - - #[test] - fn test_scan_folder_empty() { - let dir = tempfile::tempdir().unwrap(); - let files = scan_folder_for_media(dir.path()).unwrap(); - assert!(files.is_empty()); - } - - #[test] - fn test_scan_folder_nonexistent() { - let result = scan_folder_for_media(Path::new("/nonexistent/path/to/folder")); - assert!(result.is_err()); - } - - #[test] - fn test_scan_folder_single_file() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("episode.mp4"); - fs::write(&file_path, "video").unwrap(); - - let files = scan_folder_for_media(&file_path).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].extension, "mp4"); - } - - #[test] - fn test_scan_folder_nested() { - let dir = tempfile::tempdir().unwrap(); - let sub = dir.path().join("subfolder"); - fs::create_dir(&sub).unwrap(); - fs::write(sub.join("episode.mkv"), "video").unwrap(); - fs::write(dir.path().join("movie.avi"), "video2").unwrap(); - - let files = scan_folder_for_media(dir.path()).unwrap(); - assert_eq!(files.len(), 2); - } - - // ── Tests for disk_scan media_type routing ─────────────────────────── - // disk_scan requires a real PgPool + data, so we test the media_type - // dispatch logic via the public function on a non-existent path to - // confirm "tv" is accepted before the path-existence check. - - mod disk_scan_media_type { - use super::*; - use sqlx::postgres::PgPoolOptions; - - fn dummy_pool() -> PgPool { - PgPoolOptions::new() - .max_connections(1) - .connect_lazy("postgresql://fake:fake@localhost:5432/fake") - .expect("lazy pool") - } - - #[tokio::test] - async fn test_disk_scan_rejects_unknown_media_type() { - let pool = dummy_pool(); - let dir = tempfile::tempdir().unwrap(); - - let result = disk_scan(&pool, dir.path(), "anime").await; - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("unknown media_type"), - "expected 'unknown media_type' error, got: {err_msg}" - ); - } - - #[tokio::test] - async fn test_disk_scan_accepts_series_media_type() { - // "series" should pass the media_type check and reach the DB - // query phase (which will fail with our dummy pool, not with - // "unknown media_type") - let pool = dummy_pool(); - let dir = tempfile::tempdir().unwrap(); - - let result = disk_scan(&pool, dir.path(), "series").await; - // Will either succeed (empty scan) or fail on DB — but NOT - // with "unknown media_type" - if let Err(e) = &result { - assert!( - !e.to_string().contains("unknown media_type"), - "series should be accepted, got: {e}" - ); - } - } - - #[tokio::test] - async fn test_disk_scan_accepts_tv_media_type() { - // "tv" must be accepted as an alias for "series" - let pool = dummy_pool(); - let dir = tempfile::tempdir().unwrap(); - - let result = disk_scan(&pool, dir.path(), "tv").await; - if let Err(e) = &result { - assert!( - !e.to_string().contains("unknown media_type"), - "tv should be accepted as alias for series, got: {e}" - ); - } - } - - #[tokio::test] - async fn test_disk_scan_accepts_movie_media_type() { - let pool = dummy_pool(); - let dir = tempfile::tempdir().unwrap(); - - let result = disk_scan(&pool, dir.path(), "movie").await; - if let Err(e) = &result { - assert!( - !e.to_string().contains("unknown media_type"), - "movie should be accepted, got: {e}" - ); - } - } - - #[tokio::test] - async fn test_disk_scan_nonexistent_path_errors() { - let pool = dummy_pool(); - let result = disk_scan(&pool, Path::new("/nonexistent/media/path"), "movie").await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("does not exist")); - } - } - - // ── Test for process_single_file media_type routing ────────────────── - - mod import_media_type { - #[test] - fn test_import_context_media_type_values() { - // Verify the media_type string values that ImportContext accepts - // are documented: "series", "tv", and "movie" should all be valid. - let valid_types = ["series", "tv", "movie"]; - let invalid_types = ["anime", "TV", "Series", "Movie", ""]; - - for t in &valid_types { - // These should match in process_single_file's match arms - assert!( - matches!(*t, "series" | "tv" | "movie"), - "{t} should be a valid media_type" - ); - } - for t in &invalid_types { - assert!( - !matches!(*t, "series" | "tv" | "movie"), - "{t} should NOT be a valid media_type" - ); - } - } - } - - // ── Additional media extension tests ────────────────────────────── - - #[test] - fn test_is_media_extension_case_sensitive() { - // The function expects lowercase input (callers normalize) - assert!(!is_media_extension("MKV")); - assert!(!is_media_extension("MP4")); - } - - // ── Additional sample detection tests ───────────────────────────── - - #[test] - fn test_is_sample_exactly_at_threshold() { - let path = Path::new("/downloads/sample.mkv"); - // Exactly at threshold — not a sample - assert!(!is_sample(path, SAMPLE_SIZE_THRESHOLD)); - } - - #[test] - fn test_is_sample_just_below_threshold() { - let path = Path::new("/downloads/sample.mkv"); - assert!(is_sample(path, SAMPLE_SIZE_THRESHOLD - 1)); - } - - #[test] - fn test_is_sample_in_subdirectory() { - let path = Path::new("/downloads/Movie.2024/Sample/sample_video.mkv"); - assert!(is_sample(path, 5 * 1024 * 1024)); - } - - #[test] - fn test_is_sample_zero_size() { - let path = Path::new("/downloads/sample.mkv"); - assert!(is_sample(path, 0)); - } - - // ── Folder scanning edge cases ──────────────────────────────────── - - #[test] - fn test_scan_folder_multiple_extensions() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("movie.mkv"), "video").unwrap(); - fs::write(dir.path().join("movie.mp4"), "video2").unwrap(); - fs::write(dir.path().join("movie.avi"), "video3").unwrap(); - fs::write(dir.path().join("movie.wmv"), "video4").unwrap(); - fs::write(dir.path().join("movie.ts"), "video5").unwrap(); - fs::write(dir.path().join("movie.m4v"), "video6").unwrap(); - fs::write(dir.path().join("movie.flv"), "video7").unwrap(); - fs::write(dir.path().join("movie.mov"), "video8").unwrap(); - fs::write(dir.path().join("movie.webm"), "video9").unwrap(); - fs::write(dir.path().join("notes.txt"), "text").unwrap(); - - let files = scan_folder_for_media(dir.path()).unwrap(); - assert_eq!(files.len(), 9); - } - - #[test] - fn test_scan_folder_deeply_nested() { - let dir = tempfile::tempdir().unwrap(); - let deep = dir.path().join("a").join("b").join("c"); - fs::create_dir_all(&deep).unwrap(); - fs::write(deep.join("episode.mkv"), "video").unwrap(); - - let files = scan_folder_for_media(dir.path()).unwrap(); - assert_eq!(files.len(), 1); - } - - #[test] - fn test_scan_folder_single_non_media_file() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("readme.txt"); - fs::write(&file_path, "not video").unwrap(); - - let files = scan_folder_for_media(&file_path).unwrap(); - assert!(files.is_empty()); - } - - #[test] - fn test_scan_folder_captures_size() { - let dir = tempfile::tempdir().unwrap(); - let content = vec![0u8; 1024]; - fs::write(dir.path().join("movie.mp4"), &content).unwrap(); - - let files = scan_folder_for_media(dir.path()).unwrap(); - assert_eq!(files.len(), 1); - assert_eq!(files[0].size, 1024); - } - - #[test] - fn test_scan_folder_captures_extension() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("movie.webm"), "video").unwrap(); - - let files = scan_folder_for_media(dir.path()).unwrap(); - assert_eq!(files[0].extension, "webm"); - } - - // ── ImportResult/DiskScanResult types ───────────────────────────── - - #[test] - fn test_import_result_default() { - let result = ImportResult { - imported_files: Vec::new(), - skipped_files: Vec::new(), - errors: Vec::new(), - }; - assert!(result.imported_files.is_empty()); - assert!(result.skipped_files.is_empty()); - assert!(result.errors.is_empty()); - } - - #[test] - fn test_disk_scan_result_default() { - let result = DiskScanResult { - files_found: 0, - files_matched: 0, - files_unmatched: 0, - files_already_tracked: 0, - unmatched_files: Vec::new(), - }; - assert_eq!(result.files_found, 0); - } - - #[test] - fn test_import_result_serde() { - let result = ImportResult { - imported_files: vec![ImportedFile { - source_path: "/downloads/movie.mkv".to_string(), - dest_path: "/movies/Movie (2024)/Movie (2024).mkv".to_string(), - media_file_id: 42, - quality: "Bluray1080p".to_string(), - size: 1_500_000_000, - }], - skipped_files: vec!["sample.mkv".to_string()], - errors: Vec::new(), - }; - let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains("importedFiles")); - assert!(json.contains("skippedFiles")); - assert!(json.contains("mediaFileId")); - } - - #[test] - fn test_disk_scan_result_serde() { - let result = DiskScanResult { - files_found: 10, - files_matched: 8, - files_unmatched: 2, - files_already_tracked: 5, - unmatched_files: vec!["/tv/Unknown/file.mkv".to_string()], - }; - let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains("filesFound")); - assert!(json.contains("filesMatched")); - assert!(json.contains("filesAlreadyTracked")); - assert!(json.contains("unmatchedFiles")); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/src/naming.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/src/naming.rs deleted file mode 100644 index 4cb4478b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-import/src/naming.rs +++ /dev/null @@ -1,700 +0,0 @@ -use once_cell::sync::Lazy; -use regex::Regex; -use stackarr_parser::Quality; - -// Matches `{token}` or `{token:padding}` patterns in naming format strings. -static RE_TOKEN: Lazy = Lazy::new(|| Regex::new(r"\{([^}]+)\}").unwrap()); - -// ── Quality display ───────────────────────────────────────────────────────── - -/// Human-readable quality name (e.g. "Bluray-1080p", "WEBDL-2160p"). -fn quality_title(q: &Quality) -> &'static str { - match q { - Quality::Unknown => "Unknown", - Quality::SDTV => "SDTV", - Quality::DVD => "DVD", - Quality::WEBDL480p => "WEBDL-480p", - Quality::HDTV720p => "HDTV-720p", - Quality::HDTV1080p => "HDTV-1080p", - Quality::Raw => "Raw-HD", - Quality::WEBDL720p => "WEBDL-720p", - Quality::Bluray720p => "Bluray-720p", - Quality::WEBDL1080p => "WEBDL-1080p", - Quality::Bluray1080p => "Bluray-1080p", - Quality::HDTV2160p => "HDTV-2160p", - Quality::WEBDL2160p => "WEBDL-2160p", - Quality::Bluray2160p => "Bluray-2160p", - Quality::DVDRip => "DVDRip", - Quality::WEBRip480p => "WEBRip-480p", - Quality::WEBRip720p => "WEBRip-720p", - Quality::WEBRip1080p => "WEBRip-1080p", - Quality::WEBRip2160p => "WEBRip-2160p", - Quality::Remux1080p => "Remux-1080p", - Quality::Remux2160p => "Remux-2160p", - } -} - -// ── Sanitization ──────────────────────────────────────────────────────────── - -/// Characters that are illegal in filenames on Windows/Linux/macOS. -const ILLEGAL_CHARS: &[char] = &['/', '\\', '*', '?', '"', '<', '>', '|']; - -/// Sanitize a filename by replacing illegal characters, handling colons via the -/// configured replacement strategy, and trimming whitespace. -/// -/// `colon_replacement` values: -/// - `"smart"` — replace `: ` with ` - `, standalone `:` with `-` -/// - `"dash"` — replace `:` with `-` -/// - `"space"` — replace `:` with ` ` -/// - `"spacedash"` — replace `:` with ` -` -/// - anything else — just remove colons -pub fn sanitize_filename(name: &str, colon_replacement: &str) -> String { - // First handle colons according to the replacement strategy - let without_colons = match colon_replacement { - "smart" => name - .replace(": ", " - ") - .replace(':', "-"), - "dash" => name.replace(':', "-"), - "space" => name.replace(':', " "), - "spacedash" => name.replace(':', " -"), - _ => name.replace(':', ""), - }; - - // Remove remaining illegal characters - let cleaned: String = without_colons - .chars() - .filter(|c| !ILLEGAL_CHARS.contains(c)) - .collect(); - - // Collapse multiple spaces, trim - let mut result = String::with_capacity(cleaned.len()); - let mut prev_space = false; - for ch in cleaned.chars() { - if ch == ' ' { - if !prev_space { - result.push(' '); - } - prev_space = true; - } else { - prev_space = false; - result.push(ch); - } - } - - result.trim().to_string() -} - -// ── Padding helper ────────────────────────────────────────────────────────── - -/// Given a token like `season:00` or `episode:000`, extract the name portion -/// and the padding width. Returns `(name, padding_width)`. -fn parse_token_padding(token: &str) -> (&str, Option) { - if let Some((name, pad)) = token.split_once(':') { - // Padding is determined by the number of digits: "00" = 2, "000" = 3 - let width = pad.len(); - if width > 0 && pad.chars().all(|c| c == '0') { - Some((name, Some(width))) - } else { - // Not a valid padding pattern, treat the whole thing as the name - Some((token, None)) - } - } else { - Some((token, None)) - } - .unwrap_or((token, None)) -} - -/// Format a number with the given zero-padding width. -fn pad_number(n: i32, width: Option) -> String { - match width { - Some(w) => format!("{:0>width$}", n, width = w), - None => n.to_string(), - } -} - -// ── Episode filename builder ──────────────────────────────────────────────── - -/// Build a target filename from a naming format string and episode metadata. -/// -/// Supported tokens: `{Series Title}`, `{season:00}`, `{episode:00}`, -/// `{Episode Title}`, `{Quality Title}`, `{Release Year}`, `{Release Group}`, -/// `{Absolute Episode}`. -pub fn build_episode_filename( - format: &str, - series_title: &str, - season: i32, - episode: i32, - episode_title: Option<&str>, - quality: &Quality, - release_group: Option<&str>, - absolute_episode: Option, -) -> String { - RE_TOKEN - .replace_all(format, |caps: ®ex::Captures| { - let raw_token = &caps[1]; - let (name, padding) = parse_token_padding(raw_token); - - match name { - "Series Title" => series_title.to_string(), - "season" => pad_number(season, padding), - "episode" => pad_number(episode, padding), - "Episode Title" => episode_title.unwrap_or("").to_string(), - "Quality Title" => quality_title(quality).to_string(), - "Release Year" => "".to_string(), // not applicable for episodes in most cases - "Release Group" => release_group.unwrap_or("").to_string(), - "Absolute Episode" => { - absolute_episode - .map(|n| pad_number(n, padding)) - .unwrap_or_default() - } - _ => String::new(), - } - }) - .to_string() -} - -// ── Movie filename builder ────────────────────────────────────────────────── - -/// Build a target filename from a naming format string and movie metadata. -/// -/// Supported tokens: `{Movie Title}`, `{Release Year}`, `{Quality Title}`, -/// `{Edition Tags}`, `{Release Group}`. -pub fn build_movie_filename( - format: &str, - movie_title: &str, - year: Option, - quality: &Quality, - edition: Option<&str>, - release_group: Option<&str>, -) -> String { - RE_TOKEN - .replace_all(format, |caps: ®ex::Captures| { - let raw_token = &caps[1]; - let (name, _padding) = parse_token_padding(raw_token); - - match name { - "Movie Title" => movie_title.to_string(), - "Release Year" => year.map(|y| y.to_string()).unwrap_or_default(), - "Quality Title" => quality_title(quality).to_string(), - "Edition Tags" => edition.unwrap_or("").to_string(), - "Release Group" => release_group.unwrap_or("").to_string(), - _ => String::new(), - } - }) - .to_string() -} - -// ── Season folder builder ─────────────────────────────────────────────────── - -/// Build a season folder name from a format string and season number. -/// -/// Supported tokens: `{season:00}`. -pub fn build_season_folder(format: &str, season: i32) -> String { - RE_TOKEN - .replace_all(format, |caps: ®ex::Captures| { - let raw_token = &caps[1]; - let (name, padding) = parse_token_padding(raw_token); - - match name { - "season" => pad_number(season, padding), - _ => String::new(), - } - }) - .to_string() -} - -// ── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_episode_standard() { - let result = build_episode_filename( - "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]", - "The Office", - 1, - 1, - Some("Pilot"), - &Quality::HDTV720p, - None, - None, - ); - assert_eq!( - result, - "The Office - S01E01 - Pilot [HDTV-720p]" - ); - } - - #[test] - fn test_build_episode_with_group() { - let result = build_episode_filename( - "{Series Title} - S{season:00}E{episode:00} - {Episode Title} [{Quality Title}]-{Release Group}", - "Breaking Bad", - 5, - 16, - Some("Felina"), - &Quality::Bluray1080p, - Some("GROUP"), - None, - ); - assert_eq!( - result, - "Breaking Bad - S05E16 - Felina [Bluray-1080p]-GROUP" - ); - } - - #[test] - fn test_build_episode_anime_absolute() { - let result = build_episode_filename( - "{Series Title} - S{season:00}E{episode:00} - {Absolute Episode} - {Episode Title} [{Quality Title}]", - "Naruto", - 1, - 1, - Some("Enter Naruto"), - &Quality::WEBDL1080p, - None, - Some(1), - ); - assert_eq!( - result, - "Naruto - S01E01 - 1 - Enter Naruto [WEBDL-1080p]" - ); - } - - #[test] - fn test_build_episode_three_digit_padding() { - let result = build_episode_filename( - "{Series Title} - {Absolute Episode:000}", - "One Piece", - 0, - 0, - None, - &Quality::Unknown, - None, - Some(42), - ); - assert_eq!(result, "One Piece - 042"); - } - - #[test] - fn test_build_movie_standard() { - let result = build_movie_filename( - "{Movie Title} ({Release Year}) [{Quality Title}]", - "Inception", - Some(2010), - &Quality::Bluray1080p, - None, - None, - ); - assert_eq!(result, "Inception (2010) [Bluray-1080p]"); - } - - #[test] - fn test_build_movie_with_edition() { - let result = build_movie_filename( - "{Movie Title} ({Release Year}) {Edition Tags} [{Quality Title}]", - "Blade Runner", - Some(1982), - &Quality::Remux2160p, - Some("Directors Cut"), - None, - ); - assert_eq!( - result, - "Blade Runner (1982) Directors Cut [Remux-2160p]" - ); - } - - #[test] - fn test_build_movie_no_year() { - let result = build_movie_filename( - "{Movie Title} ({Release Year}) [{Quality Title}]", - "Test Movie", - None, - &Quality::WEBDL1080p, - None, - None, - ); - assert_eq!(result, "Test Movie () [WEBDL-1080p]"); - } - - #[test] - fn test_build_season_folder() { - assert_eq!(build_season_folder("Season {season:00}", 3), "Season 03"); - assert_eq!(build_season_folder("Season {season:00}", 12), "Season 12"); - assert_eq!( - build_season_folder("S{season:000}", 5), - "S005" - ); - } - - #[test] - fn test_sanitize_smart_colon() { - // ": " is replaced with " - " - assert_eq!( - sanitize_filename("Show: The Beginning", "smart"), - "Show - The Beginning" - ); - assert_eq!( - sanitize_filename("Doctor Who: Series 14", "smart"), - "Doctor Who - Series 14" - ); - // Standalone ":" (no trailing space) is replaced with "-" - assert_eq!( - sanitize_filename("Title:Subtitle", "smart"), - "Title-Subtitle" - ); - } - - #[test] - fn test_sanitize_dash_colon() { - assert_eq!( - sanitize_filename("Star Trek: Picard", "dash"), - "Star Trek- Picard" - ); - } - - #[test] - fn test_sanitize_space_colon() { - assert_eq!( - sanitize_filename("Star Trek: Picard", "space"), - "Star Trek Picard" - ); - } - - #[test] - fn test_sanitize_illegal_chars() { - assert_eq!( - sanitize_filename("file/name*with?bad", "smart"), - "filenamewithbadchars" - ); - } - - #[test] - fn test_sanitize_collapses_spaces() { - assert_eq!( - sanitize_filename("too many spaces", "smart"), - "too many spaces" - ); - } - - #[test] - fn test_unknown_token_replaced_with_empty() { - let result = build_episode_filename( - "{Series Title} {Unknown Token}", - "Test", - 1, - 1, - None, - &Quality::Unknown, - None, - None, - ); - assert_eq!(result, "Test "); - } - - // ── Episode filename edge cases ─────────────────────────────────── - - #[test] - fn test_build_episode_high_season_numbers() { - let result = build_episode_filename( - "{Series Title} - S{season:00}E{episode:00}", - "Grey's Anatomy", - 20, - 15, - None, - &Quality::WEBDL1080p, - None, - None, - ); - assert_eq!(result, "Grey's Anatomy - S20E15"); - } - - #[test] - fn test_build_episode_specials_season_zero() { - let result = build_episode_filename( - "{Series Title} - S{season:00}E{episode:00} - {Episode Title}", - "Doctor Who", - 0, - 5, - Some("Special Episode"), - &Quality::HDTV1080p, - None, - None, - ); - assert_eq!(result, "Doctor Who - S00E05 - Special Episode"); - } - - #[test] - fn test_build_episode_no_episode_title() { - let result = build_episode_filename( - "{Series Title} - S{season:00}E{episode:00} - {Episode Title}", - "Unnamed Show", - 1, - 1, - None, - &Quality::HDTV720p, - None, - None, - ); - assert_eq!(result, "Unnamed Show - S01E01 - "); - } - - #[test] - fn test_build_episode_single_digit_no_padding() { - let result = build_episode_filename( - "{Series Title} S{season}E{episode}", - "Test", - 1, - 1, - None, - &Quality::Unknown, - None, - None, - ); - assert_eq!(result, "Test S1E1"); - } - - #[test] - fn test_build_episode_all_quality_types() { - let qualities = vec![ - (Quality::SDTV, "SDTV"), - (Quality::DVD, "DVD"), - (Quality::WEBDL480p, "WEBDL-480p"), - (Quality::WEBRip480p, "WEBRip-480p"), - (Quality::HDTV720p, "HDTV-720p"), - (Quality::WEBDL720p, "WEBDL-720p"), - (Quality::WEBRip720p, "WEBRip-720p"), - (Quality::Bluray720p, "Bluray-720p"), - (Quality::HDTV1080p, "HDTV-1080p"), - (Quality::WEBDL1080p, "WEBDL-1080p"), - (Quality::WEBRip1080p, "WEBRip-1080p"), - (Quality::Bluray1080p, "Bluray-1080p"), - (Quality::Remux1080p, "Remux-1080p"), - (Quality::HDTV2160p, "HDTV-2160p"), - (Quality::WEBDL2160p, "WEBDL-2160p"), - (Quality::WEBRip2160p, "WEBRip-2160p"), - (Quality::Bluray2160p, "Bluray-2160p"), - (Quality::Remux2160p, "Remux-2160p"), - (Quality::Raw, "Raw-HD"), - ]; - - for (quality, expected_name) in qualities { - let result = build_episode_filename( - "[{Quality Title}]", - "X", - 1, - 1, - None, - &quality, - None, - None, - ); - assert_eq!(result, format!("[{expected_name}]"), "quality {quality:?} mismatch"); - } - } - - #[test] - fn test_build_episode_absolute_with_padding() { - let result = build_episode_filename( - "{Series Title} - {Absolute Episode:000}", - "Bleach", - 1, - 1, - None, - &Quality::Unknown, - None, - Some(366), - ); - assert_eq!(result, "Bleach - 366"); - } - - #[test] - fn test_build_episode_absolute_no_value() { - let result = build_episode_filename( - "{Series Title} - {Absolute Episode:000}", - "Show", - 1, - 1, - None, - &Quality::Unknown, - None, - None, - ); - assert_eq!(result, "Show - "); - } - - // ── Movie filename edge cases ───────────────────────────────────── - - #[test] - fn test_build_movie_with_release_group() { - let result = build_movie_filename( - "{Movie Title} ({Release Year}) [{Quality Title}]-{Release Group}", - "Dune", - Some(2021), - &Quality::Remux2160p, - None, - Some("FraMeSToR"), - ); - assert_eq!(result, "Dune (2021) [Remux-2160p]-FraMeSToR"); - } - - #[test] - fn test_build_movie_no_optional_tokens() { - let result = build_movie_filename( - "{Movie Title}", - "Simple Movie", - None, - &Quality::Unknown, - None, - None, - ); - assert_eq!(result, "Simple Movie"); - } - - #[test] - fn test_build_movie_all_tokens() { - let result = build_movie_filename( - "{Movie Title} ({Release Year}) {Edition Tags} [{Quality Title}]-{Release Group}", - "Gladiator", - Some(2000), - &Quality::Bluray2160p, - Some("Extended Edition"), - Some("EPSiLON"), - ); - assert_eq!( - result, - "Gladiator (2000) Extended Edition [Bluray-2160p]-EPSiLON" - ); - } - - // ── Season folder edge cases ────────────────────────────────────── - - #[test] - fn test_build_season_folder_season_zero() { - assert_eq!(build_season_folder("Season {season:00}", 0), "Season 00"); - } - - #[test] - fn test_build_season_folder_high_season() { - assert_eq!(build_season_folder("Season {season:00}", 99), "Season 99"); - } - - #[test] - fn test_build_season_folder_no_padding() { - assert_eq!(build_season_folder("Season {season}", 3), "Season 3"); - } - - #[test] - fn test_build_season_folder_specials() { - assert_eq!(build_season_folder("Specials", 0), "Specials"); - } - - // ── Sanitization edge cases ─────────────────────────────────────── - - #[test] - fn test_sanitize_spacedash_colon() { - assert_eq!( - sanitize_filename("Star Trek: Picard", "spacedash"), - "Star Trek - Picard" - ); - } - - #[test] - fn test_sanitize_remove_colon() { - assert_eq!( - sanitize_filename("Title: Subtitle", "delete"), - "Title Subtitle" - ); - } - - #[test] - fn test_sanitize_empty_string() { - assert_eq!(sanitize_filename("", "smart"), ""); - } - - #[test] - fn test_sanitize_only_illegal_chars() { - assert_eq!(sanitize_filename("/*?\"<>|", "smart"), ""); - } - - #[test] - fn test_sanitize_preserves_normal_chars() { - assert_eq!( - sanitize_filename("Normal Title 2024", "smart"), - "Normal Title 2024" - ); - } - - #[test] - fn test_sanitize_trims_whitespace() { - assert_eq!( - sanitize_filename(" Title ", "smart"), - "Title" - ); - } - - #[test] - fn test_sanitize_backslash_removed() { - assert_eq!( - sanitize_filename("path\\to\\file", "smart"), - "pathtofile" - ); - } - - #[test] - fn test_sanitize_multiple_colons_smart() { - assert_eq!( - sanitize_filename("Title: Part One: Remastered", "smart"), - "Title - Part One - Remastered" - ); - } - - // ── Token padding helper ────────────────────────────────────────── - - #[test] - fn test_parse_token_padding_with_two_digit() { - let (name, padding) = parse_token_padding("season:00"); - assert_eq!(name, "season"); - assert_eq!(padding, Some(2)); - } - - #[test] - fn test_parse_token_padding_with_three_digit() { - let (name, padding) = parse_token_padding("episode:000"); - assert_eq!(name, "episode"); - assert_eq!(padding, Some(3)); - } - - #[test] - fn test_parse_token_padding_no_padding() { - let (name, padding) = parse_token_padding("Series Title"); - assert_eq!(name, "Series Title"); - assert_eq!(padding, None); - } - - #[test] - fn test_parse_token_padding_non_zero_padding() { - // "12" is not a valid zero-padding pattern - let (name, padding) = parse_token_padding("season:12"); - assert_eq!(name, "season:12"); - assert_eq!(padding, None); - } - - #[test] - fn test_pad_number_no_padding() { - assert_eq!(pad_number(5, None), "5"); - assert_eq!(pad_number(42, None), "42"); - } - - #[test] - fn test_pad_number_with_padding() { - assert_eq!(pad_number(1, Some(2)), "01"); - assert_eq!(pad_number(10, Some(2)), "10"); - assert_eq!(pad_number(1, Some(3)), "001"); - assert_eq!(pad_number(100, Some(3)), "100"); - assert_eq!(pad_number(1000, Some(3)), "1000"); // exceeds padding, still works - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/Cargo.toml deleted file mode 100644 index 60a4f4cb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "stackarr-indexer" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-parser = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -reqwest = { workspace = true } -quick-xml = { workspace = true, features = ["serialize"] } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -async-trait = { workspace = true } -url = "2" -urlencoding = { workspace = true } -futures = "0.3" -stackarr-cardigann = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/indexarr.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/indexarr.rs deleted file mode 100644 index 0f1b9f10..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/indexarr.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, bail}; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use tracing::debug; - -use crate::newznab::{self, Protocol, ReleaseInfo}; - -/// Client for the Indexarr sidecar / distributed indexer. -/// -/// Indexarr exposes both a Torznab-compatible XML endpoint and a richer REST -/// JSON API. This client wraps both. -pub struct IndexarrClient { - base_url: String, - api_key: String, - http: Client, -} - -/// Health status returned by Indexarr's `/api/v1/health` endpoint. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IndexarrStatus { - pub version: String, - pub peer_count: Option, - pub index_count: Option, - pub healthy: bool, -} - -/// Filters for the Indexarr REST search endpoint. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RestSearchFilters { - pub categories: Option>, - pub min_size: Option, - pub max_size: Option, - pub min_seeders: Option, - pub max_age_days: Option, -} - -impl IndexarrClient { - pub fn new(base_url: impl Into, api_key: impl Into) -> Self { - Self { - base_url: base_url.into().trim_end_matches('/').to_string(), - api_key: api_key.into(), - http: Client::new(), - } - } - - /// Perform a Torznab-compatible XML search (delegates to [`NewznabClient`] - /// XML parsing under the hood). - pub async fn torznab_search( - &self, - params: &HashMap, - ) -> anyhow::Result> { - let mut url = format!( - "{}/api?apikey={}&o=xml", - self.base_url, self.api_key - ); - for (k, v) in params { - url.push_str(&format!("&{k}={v}")); - } - - debug!(url = %url, "indexarr torznab search"); - let body = self - .http - .get(&url) - .send() - .await - .context("indexarr torznab request failed")? - .text() - .await - .context("indexarr torznab response body")?; - - // Re-use the newznab XML parser. Indexarr always speaks torznab. - newznab::parse_newznab_xml_public(&body, 0, "Indexarr", Protocol::Torrent) - } - - /// Perform a richer REST/JSON search against Indexarr's own API. - pub async fn rest_search( - &self, - query: &str, - filters: &RestSearchFilters, - ) -> anyhow::Result> { - let url = format!("{}/api/v1/search", self.base_url); - debug!(url = %url, query = %query, "indexarr REST search"); - - let resp = self - .http - .get(&url) - .header("X-Api-Key", &self.api_key) - .query(&[("q", query)]) - .send() - .await - .context("indexarr REST search request failed")?; - - if !resp.status().is_success() { - bail!("indexarr REST search returned HTTP {}", resp.status()); - } - - let mut releases: Vec = resp - .json() - .await - .context("failed to parse indexarr REST response")?; - - // Apply local filters that the API might not enforce. - if let Some(ref cats) = filters.categories { - releases.retain(|r| r.categories.iter().any(|c| cats.contains(c))); - } - if let Some(min) = filters.min_size { - releases.retain(|r| r.size >= min); - } - if let Some(max) = filters.max_size { - releases.retain(|r| r.size <= max); - } - if let Some(min_s) = filters.min_seeders { - releases.retain(|r| r.seeders.unwrap_or(0) >= min_s); - } - if let Some(max_age) = filters.max_age_days { - releases.retain(|r| r.age_days <= max_age); - } - - Ok(releases) - } - - /// Fetch the Indexarr service status. - pub async fn status(&self) -> anyhow::Result { - let url = format!("{}/api/v1/status", self.base_url); - let resp = self - .http - .get(&url) - .header("X-Api-Key", &self.api_key) - .send() - .await - .context("indexarr status request failed")?; - - if !resp.status().is_success() { - bail!("indexarr status returned HTTP {}", resp.status()); - } - resp.json() - .await - .context("failed to parse indexarr status response") - } - - /// Quick health check — returns `Ok(())` if Indexarr is reachable and - /// reports healthy. - pub async fn health_check(&self) -> anyhow::Result<()> { - let status = self.status().await?; - if status.healthy { - Ok(()) - } else { - bail!("indexarr reports unhealthy"); - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/lib.rs deleted file mode 100644 index 376a4554..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod indexarr; -pub mod manager; -pub mod newznab; -pub mod search; - -pub use indexarr::{IndexarrClient, IndexarrStatus, RestSearchFilters}; -pub use manager::IndexerManager; -pub use newznab::{NewznabClient, ReleaseInfo}; -pub use search::{MovieSearchCriteria, SearchService, TextSearchCriteria, TvSearchCriteria}; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/manager.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/manager.rs deleted file mode 100644 index a5f18621..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/manager.rs +++ /dev/null @@ -1,361 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use crate::indexarr::IndexarrClient; -use crate::newznab::{NewznabClient, Protocol, ReleaseInfo}; -use crate::search::{MovieSearchCriteria, SearchService, TextSearchCriteria, TvSearchCriteria}; - -use stackarr_cardigann::search::{CardigannIndexer, CardigannRelease, SearchQuery, SearchType}; -use stackarr_cardigann::CardigannEngine; - -/// Configuration for a registered Newznab/Torznab indexer. -struct RegisteredIndexer { - id: i64, - #[allow(dead_code)] - name: String, - enabled: bool, - priority: i32, - client: Arc, -} - -/// A registered Cardigann indexer. -struct RegisteredCardigannIndexer { - id: i64, - name: String, - enabled: bool, - priority: i32, - indexer: Arc, -} - -/// Manages all configured indexers (Newznab, Torznab, and Cardigann) and -/// exposes search through [`SearchService`]. -pub struct IndexerManager { - indexers: Vec, - cardigann_indexers: Vec, - indexarr: Option>, - cardigann_engine: Option>, -} - -impl IndexerManager { - pub fn new() -> Self { - Self { - indexers: Vec::new(), - cardigann_indexers: Vec::new(), - indexarr: None, - cardigann_engine: None, - } - } - - /// Set the Cardigann engine for loading definitions. - pub fn set_cardigann_engine(&mut self, engine: Arc) { - self.cardigann_engine = Some(engine); - } - - /// Get the Cardigann engine. - pub fn cardigann_engine(&self) -> Option<&Arc> { - self.cardigann_engine.as_ref() - } - - /// Set the Indexarr sidecar client for inclusion in search fanout. - pub fn set_indexarr(&mut self, client: Arc) { - self.indexarr = Some(client); - } - - /// Register a new Newznab/Torznab indexer. - pub fn add_indexer( - &mut self, - id: i64, - name: impl Into, - base_url: impl Into, - api_key: impl Into, - protocol: Protocol, - priority: i32, - ) { - let name_str = name.into(); - let client = Arc::new(NewznabClient::new( - base_url, - api_key, - id, - &name_str, - protocol, - )); - self.indexers.push(RegisteredIndexer { - id, - name: name_str, - enabled: true, - priority, - client, - }); - } - - /// Register a Cardigann indexer from a definition + user config. - pub fn add_cardigann_indexer( - &mut self, - id: i64, - name: impl Into, - indexer: CardigannIndexer, - priority: i32, - ) { - self.cardigann_indexers.push(RegisteredCardigannIndexer { - id, - name: name.into(), - enabled: true, - priority, - indexer: Arc::new(indexer), - }); - } - - /// Remove an indexer by database ID (checks both types). - pub fn remove_indexer(&mut self, id: i64) -> bool { - let before = self.indexers.len() + self.cardigann_indexers.len(); - self.indexers.retain(|i| i.id != id); - self.cardigann_indexers.retain(|i| i.id != id); - let after = self.indexers.len() + self.cardigann_indexers.len(); - after < before - } - - /// Enable or disable an indexer. - pub fn set_enabled(&mut self, id: i64, enabled: bool) { - if let Some(idx) = self.indexers.iter_mut().find(|i| i.id == id) { - idx.enabled = enabled; - } - if let Some(idx) = self.cardigann_indexers.iter_mut().find(|i| i.id == id) { - idx.enabled = enabled; - } - } - - /// Get a Newznab client by ID. - pub fn get_client(&self, id: i64) -> Option> { - self.indexers - .iter() - .find(|i| i.id == id) - .map(|i| Arc::clone(&i.client)) - } - - /// Number of registered indexers (all types). - pub fn len(&self) -> usize { - self.indexers.len() + self.cardigann_indexers.len() - } - - /// Whether no indexers are registered. - pub fn is_empty(&self) -> bool { - self.indexers.is_empty() && self.cardigann_indexers.is_empty() - } - - /// Build a [`SearchService`] from enabled Newznab indexers (+ Indexarr), - /// optionally filtered to specific indexer IDs. - fn build_search_service(&self, filter_ids: Option<&[i64]>) -> SearchService { - let clients: Vec> = self - .indexers - .iter() - .filter(|i| i.enabled) - .filter(|i| filter_ids.map_or(true, |ids| ids.contains(&i.id))) - .map(|i| Arc::clone(&i.client)) - .collect(); - let mut svc = SearchService::new(clients); - if let Some(ref client) = self.indexarr { - svc = svc.with_indexarr(Arc::clone(client)); - } - svc - } - - /// Get enabled Cardigann indexers for parallel search, - /// optionally filtered to specific indexer IDs. - fn enabled_cardigann_indexers(&self, filter_ids: Option<&[i64]>) -> Vec> { - self.cardigann_indexers - .iter() - .filter(|i| i.enabled) - .filter(|i| filter_ids.map_or(true, |ids| ids.contains(&i.id))) - .map(|i| Arc::clone(&i.indexer)) - .collect() - } - - /// Build a map of indexer_id → priority for stamping results. - fn priority_map(&self) -> HashMap { - let mut map = HashMap::new(); - for idx in &self.indexers { - map.insert(idx.id, idx.priority); - } - for idx in &self.cardigann_indexers { - map.insert(idx.id, idx.priority); - } - map - } - - /// Stamp each result's `indexer_priority` from the registered indexer's priority. - fn stamp_priorities(results: &mut [ReleaseInfo], priorities: &HashMap) { - for r in results.iter_mut() { - if let Some(&p) = priorities.get(&r.indexer_id) { - r.indexer_priority = p; - } - } - } - - /// Freehand text search across all enabled indexers (Newznab + Cardigann + Indexarr). - /// No media-type bias — the Prowlarr-style "search for anything" path. - pub async fn search_text( - &self, - criteria: &TextSearchCriteria, - ) -> anyhow::Result> { - let filter = criteria.indexer_ids.as_deref(); - let mut results = self.build_search_service(filter).search_text(criteria).await?; - - let cardigann_results = self.search_cardigann(&criteria.query, &criteria.categories, filter).await; - results.extend(cardigann_results); - - Self::stamp_priorities(&mut results, &self.priority_map()); - Ok(results) - } - - /// Search for a TV series across all enabled indexers (Newznab + Cardigann). - pub async fn search_series( - &self, - criteria: &TvSearchCriteria, - ) -> anyhow::Result> { - // Newznab/Indexarr search - let mut results = self.build_search_service(None).search_series(criteria).await?; - - // Cardigann search in parallel - let cardigann_results = self.search_cardigann(criteria.query.as_deref().unwrap_or(""), &criteria.categories, None).await; - results.extend(cardigann_results); - - Self::stamp_priorities(&mut results, &self.priority_map()); - Ok(results) - } - - /// Search for a movie across all enabled indexers (Newznab + Cardigann). - pub async fn search_movies( - &self, - criteria: &MovieSearchCriteria, - ) -> anyhow::Result> { - // Newznab/Indexarr search - let mut results = self.build_search_service(None).search_movies(criteria).await?; - - // Cardigann search in parallel - let cardigann_results = self.search_cardigann(criteria.query.as_deref().unwrap_or(""), &criteria.categories, None).await; - results.extend(cardigann_results); - - Self::stamp_priorities(&mut results, &self.priority_map()); - Ok(results) - } - - /// Convert a Cardigann release to our standard ReleaseInfo. - fn convert_cardigann_release(r: CardigannRelease) -> ReleaseInfo { - ReleaseInfo { - guid: r.guid, - title: r.title, - download_url: r.download_url, - info_url: r.info_url, - indexer_id: r.indexer_id, - indexer_name: r.indexer_name, - protocol: Protocol::Torrent, - size: r.size, - age_days: r.age_days, - publish_date: r.publish_date, - info_hash: r.info_hash, - magnet_url: r.magnet_url, - seeders: r.seeders, - leechers: r.leechers, - nzb_url: None, - tvdb_id: r.tvdb_id, - imdb_id: r.imdb_id, - tmdb_id: r.tmdb_id, - categories: r.categories, - indexer_flags: r.indexer_flags, - indexer_priority: 25, - } - } - - /// Search across all enabled Cardigann indexers. - async fn search_cardigann(&self, query: &str, categories: &[i32], filter_ids: Option<&[i64]>) -> Vec { - let indexers = self.enabled_cardigann_indexers(filter_ids); - if indexers.is_empty() { - return Vec::new(); - } - - let handles: Vec<_> = indexers - .into_iter() - .map(|indexer| { - let query = query.to_owned(); - let categories = categories.to_vec(); - tokio::spawn(async move { - let sq = SearchQuery { - query, - categories, - ..Default::default() - }; - indexer.search(&sq).await - }) - }) - .collect(); - - let mut all_releases = Vec::new(); - for handle in handles { - match handle.await { - Ok(Ok(releases)) => { - all_releases.extend(releases.into_iter().map(Self::convert_cardigann_release)); - } - Ok(Err(e)) => { - tracing::warn!(error = %e, "Cardigann indexer search failed"); - } - Err(e) => { - tracing::warn!(error = %e, "Cardigann search task panicked"); - } - } - } - - all_releases - } -} - -impl Default for IndexerManager { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_indexer_manager_add_remove() { - let mut mgr = IndexerManager::new(); - assert!(mgr.is_empty()); - mgr.add_indexer(1, "NZBGeek", "http://nzbgeek.info", "key1", Protocol::Usenet, 25); - mgr.add_indexer(2, "Jackett", "http://jackett:9117", "key2", Protocol::Torrent, 25); - assert_eq!(mgr.len(), 2); - assert!(mgr.remove_indexer(1)); - assert_eq!(mgr.len(), 1); - assert!(!mgr.remove_indexer(999)); - } - - #[test] - fn test_indexer_manager_set_enabled() { - let mut mgr = IndexerManager::new(); - mgr.add_indexer(1, "Test", "http://test", "key", Protocol::Usenet, 25); - let svc = mgr.build_search_service(None); - assert_eq!(svc.indexer_count(), 1); - mgr.set_enabled(1, false); - let svc = mgr.build_search_service(None); - assert_eq!(svc.indexer_count(), 0); - mgr.set_enabled(1, true); - let svc = mgr.build_search_service(None); - assert_eq!(svc.indexer_count(), 1); - } - - #[test] - fn test_indexer_manager_get_client() { - let mut mgr = IndexerManager::new(); - mgr.add_indexer(5, "MyIndexer", "http://idx", "key", Protocol::Torrent, 25); - let client = mgr.get_client(5); - assert!(client.is_some()); - assert_eq!(client.unwrap().indexer_name(), "MyIndexer"); - } - - #[test] - fn test_indexer_manager_get_client_missing() { - let mgr = IndexerManager::new(); - assert!(mgr.get_client(1).is_none()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/newznab.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/newznab.rs deleted file mode 100644 index c705e535..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/newznab.rs +++ /dev/null @@ -1,684 +0,0 @@ -use anyhow::{Context, bail}; -use chrono::{DateTime, Utc}; -use quick_xml::events::Event; -use quick_xml::reader::Reader; -use reqwest::Client; -use serde::{Deserialize, Serialize}; -use tracing::debug; - -/// A single release returned by a Newznab / Torznab indexer. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReleaseInfo { - pub guid: String, - pub title: String, - pub download_url: Option, - pub info_url: Option, - pub indexer_id: i64, - pub indexer_name: String, - pub protocol: Protocol, - pub size: i64, - pub age_days: i64, - pub publish_date: DateTime, - // Torrent-specific - pub info_hash: Option, - pub magnet_url: Option, - pub seeders: Option, - pub leechers: Option, - // Usenet-specific - pub nzb_url: Option, - // External IDs - pub tvdb_id: Option, - pub imdb_id: Option, - pub tmdb_id: Option, - // Categories - pub categories: Vec, - pub indexer_flags: Vec, - /// Priority of the source indexer (lower = higher priority). Set post-search by IndexerManager. - #[serde(default = "default_indexer_priority")] - pub indexer_priority: i32, -} - -fn default_indexer_priority() -> i32 { - 25 -} - -/// Whether this indexer speaks Newznab (usenet) or Torznab (torrent). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Protocol { - Usenet, - Torrent, -} - -impl std::fmt::Display for Protocol { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Protocol::Usenet => write!(f, "usenet"), - Protocol::Torrent => write!(f, "torrent"), - } - } -} - -/// Capabilities reported by a Newznab / Torznab indexer. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct IndexerCaps { - pub search_available: bool, - pub tv_search_available: bool, - pub movie_search_available: bool, - pub categories: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IndexerCategory { - pub id: i32, - pub name: String, -} - -/// Client for a standard Newznab (or Torznab) XML API. -pub struct NewznabClient { - base_url: String, - api_key: String, - indexer_id: i64, - indexer_name: String, - protocol: Protocol, - http: Client, -} - -impl NewznabClient { - pub fn new( - base_url: impl Into, - api_key: impl Into, - indexer_id: i64, - indexer_name: impl Into, - protocol: Protocol, - ) -> Self { - Self { - base_url: base_url.into().trim_end_matches('/').to_string(), - api_key: api_key.into(), - indexer_id, - indexer_name: indexer_name.into(), - protocol, - http: Client::new(), - } - } - - pub fn protocol(&self) -> Protocol { - self.protocol - } - - pub fn indexer_id(&self) -> i64 { - self.indexer_id - } - - pub fn indexer_name(&self) -> &str { - &self.indexer_name - } - - // ── URL building ──────────────────────────────────────────────────── - - fn api_url(&self, action: &str, extra: &[(&str, &str)]) -> String { - let mut url = format!( - "{}/api?t={}&apikey={}&o=xml", - self.base_url, - urlencoding::encode(action), - urlencoding::encode(&self.api_key), - ); - for (k, v) in extra { - url.push_str(&format!( - "&{}={}", - urlencoding::encode(k), - urlencoding::encode(v), - )); - } - url - } - - // ── Public search methods ─────────────────────────────────────────── - - /// Free-text search, optionally filtered by category IDs. - pub async fn search( - &self, - query: &str, - categories: &[i32], - ) -> anyhow::Result> { - let cats = categories - .iter() - .map(|c| c.to_string()) - .collect::>() - .join(","); - let mut params: Vec<(&str, &str)> = vec![("q", query)]; - if !cats.is_empty() { - params.push(("cat", &cats)); - } - let url = self.api_url("search", ¶ms); - self.fetch_and_parse(&url).await - } - - /// TV search by TVDB ID + season/episode. - pub async fn tv_search( - &self, - tvdbid: i64, - season: Option, - episode: Option, - ) -> anyhow::Result> { - let tvdb_str = tvdbid.to_string(); - let season_str; - let ep_str; - let mut params: Vec<(&str, &str)> = vec![("tvdbid", &tvdb_str)]; - if let Some(s) = season { - season_str = s.to_string(); - params.push(("season", &season_str)); - } - if let Some(e) = episode { - ep_str = e.to_string(); - params.push(("ep", &ep_str)); - } - let url = self.api_url("tvsearch", ¶ms); - self.fetch_and_parse(&url).await - } - - /// Movie search by IMDB or TMDB ID. - pub async fn movie_search( - &self, - imdbid: Option<&str>, - tmdbid: Option, - ) -> anyhow::Result> { - let tmdb_str; - let mut params: Vec<(&str, &str)> = Vec::new(); - if let Some(id) = imdbid { - params.push(("imdbid", id)); - } - if let Some(id) = tmdbid { - tmdb_str = id.to_string(); - params.push(("tmdbid", &tmdb_str)); - } - let url = self.api_url("movie", ¶ms); - self.fetch_and_parse(&url).await - } - - /// Fetch the indexer's capabilities (caps). - pub async fn caps(&self) -> anyhow::Result { - let url = self.api_url("caps", &[]); - let body = self - .http - .get(&url) - .send() - .await - .context("caps request failed")? - .text() - .await - .context("caps response body")?; - - parse_caps(&body) - } - - // ── Internal helpers ──────────────────────────────────────────────── - - async fn fetch_and_parse(&self, url: &str) -> anyhow::Result> { - debug!(indexer = %self.indexer_name, url = %url, "fetching releases"); - let body = self - .http - .get(url) - .send() - .await - .context("indexer request failed")? - .text() - .await - .context("indexer response body")?; - - parse_newznab_xml(&body, self.indexer_id, &self.indexer_name, self.protocol) - } -} - -// ── XML parsing ───────────────────────────────────────────────────────────── - -/// Parse a Newznab RSS-style XML response into a vec of [`ReleaseInfo`]. -/// -/// This is the public entry point used by sibling modules (e.g. `indexarr`). -pub fn parse_newznab_xml_public( - xml: &str, - indexer_id: i64, - indexer_name: &str, - protocol: Protocol, -) -> anyhow::Result> { - parse_newznab_xml(xml, indexer_id, indexer_name, protocol) -} - -fn parse_newznab_xml( - xml: &str, - indexer_id: i64, - indexer_name: &str, - protocol: Protocol, -) -> anyhow::Result> { - let mut reader = Reader::from_str(xml); - reader.config_mut().trim_text(true); - - let mut releases = Vec::new(); - let mut in_item = false; - let mut current_tag = String::new(); - - // Per-item fields - let mut title = String::new(); - let mut guid = String::new(); - let mut link = String::new(); - let mut size: i64 = 0; - let mut pub_date_str = String::new(); - let mut info_url = String::new(); - let mut info_hash: Option = None; - let mut magnet_url: Option = None; - let mut seeders: Option = None; - let mut leechers: Option = None; - let mut tvdb_id: Option = None; - let mut imdb_id: Option = None; - let mut tmdb_id: Option = None; - let mut categories: Vec = Vec::new(); - let mut indexer_flags: Vec = Vec::new(); - - let mut buf = Vec::new(); - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - if name == "item" { - in_item = true; - title.clear(); - guid.clear(); - link.clear(); - size = 0; - pub_date_str.clear(); - info_url.clear(); - info_hash = None; - magnet_url = None; - seeders = None; - leechers = None; - tvdb_id = None; - imdb_id = None; - tmdb_id = None; - categories.clear(); - indexer_flags.clear(); - } - if in_item { - current_tag = name; - } - } - Ok(Event::Empty(ref e)) if in_item => { - // Newznab extended attributes: - let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); - if tag.ends_with("attr") { - let mut attr_name = String::new(); - let mut attr_value = String::new(); - for attr in e.attributes().flatten() { - let key = String::from_utf8_lossy(attr.key.as_ref()).to_string(); - let val = String::from_utf8_lossy(&attr.value).to_string(); - match key.as_str() { - "name" => attr_name = val, - "value" => attr_value = val, - _ => {} - } - } - match attr_name.as_str() { - "size" => size = attr_value.parse().unwrap_or(size), - "tvdbid" => tvdb_id = attr_value.parse().ok(), - "imdb" | "imdbid" => imdb_id = Some(attr_value), - "tmdbid" => tmdb_id = attr_value.parse().ok(), - "seeders" => { - seeders = attr_value.parse().ok(); - } - "leechers" | "peers" => { - leechers = attr_value.parse().ok(); - } - "infohash" => info_hash = Some(attr_value), - "magneturl" => magnet_url = Some(attr_value), - "category" => { - if let Ok(cat) = attr_value.parse::() { - categories.push(cat); - } - } - _ => {} - } - } - } - Ok(Event::Text(ref e)) if in_item => { - let text = e.unescape().unwrap_or_default().to_string(); - match current_tag.as_str() { - "title" => title = text, - "guid" => guid = text, - "link" => link = text, - "pubDate" => pub_date_str = text, - "comments" => info_url = text, - "size" => size = text.parse().unwrap_or(size), - _ => {} - } - } - Ok(Event::End(ref e)) => { - let name = String::from_utf8_lossy(e.name().as_ref()).to_string(); - if name == "item" && in_item { - let publish_date = parse_rfc2822_lenient(&pub_date_str); - let age_days = (Utc::now() - publish_date).num_days().max(0); - - let nzb_url = if protocol == Protocol::Usenet { - Some(link.clone()) - } else { - None - }; - - releases.push(ReleaseInfo { - guid: if guid.is_empty() { - link.clone() - } else { - guid.clone() - }, - title: title.clone(), - download_url: if link.is_empty() { - None - } else { - Some(link.clone()) - }, - info_url: if info_url.is_empty() { - None - } else { - Some(info_url.clone()) - }, - indexer_id, - indexer_name: indexer_name.to_string(), - protocol, - size, - age_days, - publish_date, - info_hash: info_hash.clone(), - magnet_url: magnet_url.clone(), - seeders, - leechers, - nzb_url, - tvdb_id, - imdb_id: imdb_id.clone(), - tmdb_id, - categories: categories.clone(), - indexer_flags: indexer_flags.clone(), - indexer_priority: 25, - }); - - in_item = false; - } - if in_item { - current_tag.clear(); - } - } - Ok(Event::Eof) => break, - Err(e) => bail!("XML parse error: {e}"), - _ => {} - } - buf.clear(); - } - - Ok(releases) -} - -/// Parse capabilities XML. -fn parse_caps(xml: &str) -> anyhow::Result { - let mut caps = IndexerCaps::default(); - let mut reader = Reader::from_str(xml); - reader.config_mut().trim_text(true); - let mut buf = Vec::new(); - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Empty(ref e)) | Ok(Event::Start(ref e)) => { - let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); - match tag.as_str() { - "searching" | "search" => { - for attr in e.attributes().flatten() { - let key = - String::from_utf8_lossy(attr.key.as_ref()).to_string(); - let val = String::from_utf8_lossy(&attr.value).to_string(); - if key == "available" && val == "yes" { - caps.search_available = true; - } - } - } - "tv-search" => { - for attr in e.attributes().flatten() { - let key = - String::from_utf8_lossy(attr.key.as_ref()).to_string(); - let val = String::from_utf8_lossy(&attr.value).to_string(); - if key == "available" && val == "yes" { - caps.tv_search_available = true; - } - } - } - "movie-search" => { - for attr in e.attributes().flatten() { - let key = - String::from_utf8_lossy(attr.key.as_ref()).to_string(); - let val = String::from_utf8_lossy(&attr.value).to_string(); - if key == "available" && val == "yes" { - caps.movie_search_available = true; - } - } - } - "category" => { - let mut cat_id = 0i32; - let mut cat_name = String::new(); - for attr in e.attributes().flatten() { - let key = - String::from_utf8_lossy(attr.key.as_ref()).to_string(); - let val = String::from_utf8_lossy(&attr.value).to_string(); - match key.as_str() { - "id" => cat_id = val.parse().unwrap_or(0), - "name" => cat_name = val, - _ => {} - } - } - if cat_id > 0 { - caps.categories.push(IndexerCategory { - id: cat_id, - name: cat_name, - }); - } - } - _ => {} - } - } - Ok(Event::Eof) => break, - Err(e) => bail!("caps XML parse error: {e}"), - _ => {} - } - buf.clear(); - } - - Ok(caps) -} - -/// Best-effort RFC 2822 date parsing with fallback to RFC 3339. -fn parse_rfc2822_lenient(s: &str) -> DateTime { - DateTime::parse_from_rfc2822(s) - .map(|dt| dt.with_timezone(&Utc)) - .or_else(|_| DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&Utc))) - .or_else(|_| s.parse::>()) - .unwrap_or_else(|_| Utc::now()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── parse_newznab_xml ─────────────────────────────────────────────── - - #[test] - fn test_parse_newznab_xml_single_item() { - let xml = r#" - - - - Test.Release.S01E01.720p.HDTV-GROUP - abc123 - http://example.com/download/abc123 - Sat, 01 Jan 2025 12:00:00 +0000 - - -"#; - let results = parse_newznab_xml(xml, 1, "TestIndexer", Protocol::Usenet).unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].title, "Test.Release.S01E01.720p.HDTV-GROUP"); - assert_eq!(results[0].guid, "abc123"); - assert_eq!(results[0].indexer_id, 1); - assert_eq!(results[0].indexer_name, "TestIndexer"); - assert_eq!(results[0].protocol, Protocol::Usenet); - assert!(results[0].nzb_url.is_some()); - } - - #[test] - fn test_parse_newznab_xml_with_newznab_attrs() { - let xml = r#" - - - - Big.Movie.2024.1080p - xyz789 - http://example.com/nzb/xyz789 - Mon, 15 Jan 2025 08:30:00 +0000 - - - - - - - - -"#; - let results = parse_newznab_xml(xml, 2, "NZBGeek", Protocol::Usenet).unwrap(); - assert_eq!(results.len(), 1); - let r = &results[0]; - assert_eq!(r.size, 5_368_709_120); - assert_eq!(r.categories, vec![2000, 2040]); - assert_eq!(r.tvdb_id, Some(12345)); - assert_eq!(r.imdb_id.as_deref(), Some("tt9876543")); - assert_eq!(r.tmdb_id, Some(67890)); - } - - #[test] - fn test_parse_newznab_xml_torrent_attrs() { - let xml = r#" - - - - Linux.Distro.2024.x64 - tor123 - http://example.com/torrent/tor123 - Tue, 20 Feb 2025 10:00:00 +0000 - - - - - - - -"#; - let results = parse_newznab_xml(xml, 3, "Torznab", Protocol::Torrent).unwrap(); - assert_eq!(results.len(), 1); - let r = &results[0]; - assert_eq!(r.seeders, Some(42)); - assert_eq!(r.leechers, Some(10)); - assert_eq!(r.info_hash.as_deref(), Some("aabbccdd00112233445566778899aabbccddeeff")); - assert_eq!(r.magnet_url.as_deref(), Some("magnet:?xt=urn:btih:aabb")); - assert_eq!(r.size, 1_073_741_824); - assert!(r.nzb_url.is_none()); // torrent protocol, no NZB URL - } - - #[test] - fn test_parse_newznab_xml_empty_channel() { - let xml = r#" -"#; - let results = parse_newznab_xml(xml, 1, "Empty", Protocol::Usenet).unwrap(); - assert!(results.is_empty()); - } - - #[test] - fn test_parse_newznab_xml_malformed() { - let result = parse_newznab_xml("not xml at all <><><<<", 1, "Bad", Protocol::Usenet); - // Should still succeed with 0 items or error - malformed XML may partially parse - // The important thing is it doesn't panic - match result { - Ok(items) => assert!(items.is_empty()), - Err(_) => {} // error is also acceptable - } - } - - #[test] - fn test_parse_newznab_xml_multiple_items() { - let xml = r#" - - - Release Aahttp://aSat, 01 Jan 2025 00:00:00 +0000 - Release Bbhttp://bSat, 01 Jan 2025 00:00:00 +0000 - Release Cchttp://cSat, 01 Jan 2025 00:00:00 +0000 - -"#; - let results = parse_newznab_xml(xml, 1, "Multi", Protocol::Usenet).unwrap(); - assert_eq!(results.len(), 3); - assert_eq!(results[0].title, "Release A"); - assert_eq!(results[1].title, "Release B"); - assert_eq!(results[2].title, "Release C"); - } - - // ── parse_caps ────────────────────────────────────────────────────── - - #[test] - fn test_parse_caps_xml() { - let xml = r#" - - - - - - - - - - -"#; - let caps = parse_caps(xml).unwrap(); - assert!(caps.search_available); - assert!(caps.tv_search_available); - assert!(!caps.movie_search_available); - assert_eq!(caps.categories.len(), 2); - assert_eq!(caps.categories[0].id, 2000); - assert_eq!(caps.categories[0].name, "Movies"); - assert_eq!(caps.categories[1].id, 5000); - assert_eq!(caps.categories[1].name, "TV"); - } - - // ── parse_rfc2822_lenient ─────────────────────────────────────────── - - #[test] - fn test_parse_rfc2822_valid() { - // Wed, 15 Jul 2026 is the correct day-of-week - let dt = parse_rfc2822_lenient("Wed, 15 Jul 2026 12:00:00 +0000"); - assert_eq!(dt.year(), 2026); - assert_eq!(dt.month(), 7); - assert_eq!(dt.day(), 15); - } - - #[test] - fn test_parse_rfc2822_rfc3339_fallback() { - let dt = parse_rfc2822_lenient("2026-06-15T10:30:00+00:00"); - assert_eq!(dt.year(), 2026); - assert_eq!(dt.month(), 6); - assert_eq!(dt.day(), 15); - } - - #[test] - fn test_parse_rfc2822_garbage_fallback() { - let before = Utc::now(); - let dt = parse_rfc2822_lenient("not a date at all"); - let after = Utc::now(); - // Should fall back to approximately now - assert!(dt >= before && dt <= after); - } -} - -// Needed for the year()/month()/day() calls in tests -#[cfg(test)] -use chrono::Datelike; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/search.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/search.rs deleted file mode 100644 index 85c18ade..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-indexer/src/search.rs +++ /dev/null @@ -1,425 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use anyhow::Context; -use serde::{Deserialize, Serialize}; -use tracing::{debug, warn}; - -use crate::indexarr::IndexarrClient; -use crate::newznab::{NewznabClient, ReleaseInfo}; - -/// Criteria for searching TV episodes. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TvSearchCriteria { - pub query: Option, - pub tvdb_id: Option, - pub season: Option, - pub episode: Option, - pub categories: Vec, -} - -/// Criteria for searching movies. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MovieSearchCriteria { - pub query: Option, - pub tmdb_id: Option, - pub imdb_id: Option, - pub categories: Vec, -} - -/// Criteria for a freehand text search (no media-type bias). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TextSearchCriteria { - pub query: String, - #[serde(default)] - pub categories: Vec, - /// When set, only search these specific indexer IDs. - #[serde(default)] - pub indexer_ids: Option>, -} - -/// Fans out searches to multiple indexers, aggregates, and deduplicates. -pub struct SearchService { - indexers: Vec>, - indexarr: Option>, -} - -impl SearchService { - pub fn new(indexers: Vec>) -> Self { - Self { - indexers, - indexarr: None, - } - } - - pub fn with_indexarr(mut self, client: Arc) -> Self { - self.indexarr = Some(client); - self - } - - /// Number of Newznab/Torznab indexers configured (excluding Indexarr). - pub fn indexer_count(&self) -> usize { - self.indexers.len() - } - - /// Freehand text search across all configured indexers (+ Indexarr if enabled). - /// No media-type bias — uses the generic `search` action on each indexer. - pub async fn search_text( - &self, - criteria: &TextSearchCriteria, - ) -> anyhow::Result> { - let handles: Vec<_> = self - .indexers - .iter() - .map(|indexer| { - let indexer = Arc::clone(indexer); - let query = criteria.query.clone(); - let cats = criteria.categories.clone(); - tokio::spawn(async move { - indexer - .search(&query, &cats) - .await - .context("free-text search failed") - }) - }) - .collect(); - - // Spawn Indexarr search in parallel if configured - let indexarr_handle = self.indexarr.as_ref().map(|client| { - let client = Arc::clone(client); - let criteria = criteria.clone(); - tokio::spawn(async move { - search_indexarr_text(&client, &criteria).await - }) - }); - - let results = futures::future::join_all(handles).await; - let mut all_releases = Vec::new(); - for result in results { - match result { - Ok(Ok(releases)) => all_releases.extend(releases), - Ok(Err(e)) => { - warn!(error = %e, "indexer text search failed"); - } - Err(e) => { - warn!(error = %e, "indexer text search task panicked"); - } - } - } - - // Collect Indexarr results - if let Some(handle) = indexarr_handle { - match handle.await { - Ok(Ok(releases)) => all_releases.extend(releases), - Ok(Err(e)) => warn!(error = %e, "Indexarr text search failed"), - Err(e) => warn!(error = %e, "Indexarr text search task panicked"), - } - } - - deduplicate(&mut all_releases); - Ok(all_releases) - } - - /// Search for a TV series across all configured indexers (+ Indexarr if enabled). - pub async fn search_series( - &self, - criteria: &TvSearchCriteria, - ) -> anyhow::Result> { - let handles: Vec<_> = self - .indexers - .iter() - .map(|indexer| { - let indexer = Arc::clone(indexer); - let criteria = criteria.clone(); - tokio::spawn(async move { search_series_single(&indexer, &criteria).await }) - }) - .collect(); - - // Spawn Indexarr search in parallel if configured - let indexarr_handle = self.indexarr.as_ref().map(|client| { - let client = Arc::clone(client); - let criteria = criteria.clone(); - tokio::spawn(async move { - search_indexarr_tv(&client, &criteria).await - }) - }); - - let results = futures::future::join_all(handles).await; - let mut all_releases = Vec::new(); - for result in results { - match result { - Ok(Ok(releases)) => all_releases.extend(releases), - Ok(Err(e)) => { - warn!(error = %e, "indexer search failed"); - } - Err(e) => { - warn!(error = %e, "indexer search task panicked"); - } - } - } - - // Collect Indexarr results - if let Some(handle) = indexarr_handle { - match handle.await { - Ok(Ok(releases)) => all_releases.extend(releases), - Ok(Err(e)) => warn!(error = %e, "Indexarr search failed"), - Err(e) => warn!(error = %e, "Indexarr search task panicked"), - } - } - - deduplicate(&mut all_releases); - Ok(all_releases) - } - - /// Search for a movie across all configured indexers (+ Indexarr if enabled). - pub async fn search_movies( - &self, - criteria: &MovieSearchCriteria, - ) -> anyhow::Result> { - let handles: Vec<_> = self - .indexers - .iter() - .map(|indexer| { - let indexer = Arc::clone(indexer); - let criteria = criteria.clone(); - tokio::spawn(async move { search_movie_single(&indexer, &criteria).await }) - }) - .collect(); - - // Spawn Indexarr search in parallel if configured - let indexarr_handle = self.indexarr.as_ref().map(|client| { - let client = Arc::clone(client); - let criteria = criteria.clone(); - tokio::spawn(async move { - search_indexarr_movie(&client, &criteria).await - }) - }); - - let results = futures::future::join_all(handles).await; - let mut all_releases = Vec::new(); - for result in results { - match result { - Ok(Ok(releases)) => all_releases.extend(releases), - Ok(Err(e)) => { - warn!(error = %e, "indexer movie search failed"); - } - Err(e) => { - warn!(error = %e, "indexer movie search task panicked"); - } - } - } - - // Collect Indexarr results - if let Some(handle) = indexarr_handle { - match handle.await { - Ok(Ok(releases)) => all_releases.extend(releases), - Ok(Err(e)) => warn!(error = %e, "Indexarr movie search failed"), - Err(e) => warn!(error = %e, "Indexarr movie search task panicked"), - } - } - - deduplicate(&mut all_releases); - Ok(all_releases) - } -} - -// ── Per-indexer helpers ───────────────────────────────────────────────────── - -async fn search_series_single( - indexer: &NewznabClient, - criteria: &TvSearchCriteria, -) -> anyhow::Result> { - debug!( - indexer = indexer.indexer_name(), - "searching for TV series" - ); - - if let Some(tvdbid) = criteria.tvdb_id { - indexer - .tv_search(tvdbid, criteria.season, criteria.episode) - .await - .context("tv_search failed") - } else if let Some(ref q) = criteria.query { - indexer - .search(q, &criteria.categories) - .await - .context("free-text search failed") - } else { - Ok(Vec::new()) - } -} - -async fn search_movie_single( - indexer: &NewznabClient, - criteria: &MovieSearchCriteria, -) -> anyhow::Result> { - debug!( - indexer = indexer.indexer_name(), - "searching for movie" - ); - - let has_ids = criteria.imdb_id.is_some() || criteria.tmdb_id.is_some(); - if has_ids { - indexer - .movie_search(criteria.imdb_id.as_deref(), criteria.tmdb_id) - .await - .context("movie_search failed") - } else if let Some(ref q) = criteria.query { - indexer - .search(q, &criteria.categories) - .await - .context("free-text search failed") - } else { - Ok(Vec::new()) - } -} - -// ── Indexarr helpers ──────────────────────────────────────────────────────── - -async fn search_indexarr_text( - client: &IndexarrClient, - criteria: &TextSearchCriteria, -) -> anyhow::Result> { - debug!("searching Indexarr (freehand text)"); - let mut params = HashMap::new(); - params.insert("t".to_string(), "search".to_string()); - params.insert("q".to_string(), criteria.query.clone()); - if !criteria.categories.is_empty() { - let cats: Vec = criteria.categories.iter().map(|c| c.to_string()).collect(); - params.insert("cat".to_string(), cats.join(",")); - } - client - .torznab_search(¶ms) - .await - .context("Indexarr text search failed") -} - -async fn search_indexarr_tv( - client: &IndexarrClient, - criteria: &TvSearchCriteria, -) -> anyhow::Result> { - debug!("searching Indexarr for TV series"); - let mut params = HashMap::new(); - params.insert("t".to_string(), "tvsearch".to_string()); - if let Some(tvdbid) = criteria.tvdb_id { - params.insert("tvdbid".to_string(), tvdbid.to_string()); - } - if let Some(season) = criteria.season { - params.insert("season".to_string(), season.to_string()); - } - if let Some(episode) = criteria.episode { - params.insert("ep".to_string(), episode.to_string()); - } - if let Some(ref q) = criteria.query { - params.insert("q".to_string(), q.clone()); - } - if !criteria.categories.is_empty() { - let cats: Vec = criteria.categories.iter().map(|c| c.to_string()).collect(); - params.insert("cat".to_string(), cats.join(",")); - } - client - .torznab_search(¶ms) - .await - .context("Indexarr TV search failed") -} - -async fn search_indexarr_movie( - client: &IndexarrClient, - criteria: &MovieSearchCriteria, -) -> anyhow::Result> { - debug!("searching Indexarr for movie"); - let mut params = HashMap::new(); - params.insert("t".to_string(), "movie".to_string()); - if let Some(ref imdb) = criteria.imdb_id { - params.insert("imdbid".to_string(), imdb.clone()); - } - if let Some(tmdb) = criteria.tmdb_id { - params.insert("tmdbid".to_string(), tmdb.to_string()); - } - if let Some(ref q) = criteria.query { - params.insert("q".to_string(), q.clone()); - } - if !criteria.categories.is_empty() { - let cats: Vec = criteria.categories.iter().map(|c| c.to_string()).collect(); - params.insert("cat".to_string(), cats.join(",")); - } - client - .torznab_search(¶ms) - .await - .context("Indexarr movie search failed") -} - -// ── Dedup ─────────────────────────────────────────────────────────────────── - -/// Remove duplicate releases by GUID. -fn deduplicate(releases: &mut Vec) { - let mut seen = HashSet::new(); - releases.retain(|r| seen.insert(r.guid.clone())); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::newznab::Protocol; - use chrono::Utc; - - fn make_release(guid: &str) -> ReleaseInfo { - ReleaseInfo { - guid: guid.to_string(), - title: format!("Release {guid}"), - download_url: None, - info_url: None, - indexer_id: 1, - indexer_name: "Test".into(), - protocol: Protocol::Usenet, - size: 0, - age_days: 0, - publish_date: Utc::now(), - info_hash: None, - magnet_url: None, - seeders: None, - leechers: None, - nzb_url: None, - tvdb_id: None, - imdb_id: None, - tmdb_id: None, - categories: vec![], - indexer_flags: vec![], - indexer_priority: 25, - } - } - - #[test] - fn test_deduplicate_removes_dupes() { - let mut releases = vec![ - make_release("aaa"), - make_release("bbb"), - make_release("aaa"), // dupe - ]; - deduplicate(&mut releases); - assert_eq!(releases.len(), 2); - assert_eq!(releases[0].guid, "aaa"); - assert_eq!(releases[1].guid, "bbb"); - } - - #[test] - fn test_deduplicate_preserves_unique() { - let mut releases = vec![ - make_release("x"), - make_release("y"), - make_release("z"), - ]; - deduplicate(&mut releases); - assert_eq!(releases.len(), 3); - } - - #[test] - fn test_deduplicate_empty() { - let mut releases: Vec = vec![]; - deduplicate(&mut releases); - assert!(releases.is_empty()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/Cargo.toml deleted file mode 100644 index df2a4cd4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "stackarr-media" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-parser = { workspace = true } -stackarr-metadata = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -reqwest = { workspace = true } - -[dev-dependencies] -stackarr-core = { workspace = true, features = ["testing"] } -uuid = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/src/import_lists.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/src/import_lists.rs deleted file mode 100644 index 0d19bacd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/src/import_lists.rs +++ /dev/null @@ -1,515 +0,0 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -use stackarr_metadata::TmdbClient; - -// ── DB row type ──────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -pub struct ImportList { - pub id: i64, - pub name: String, - pub list_type: String, - pub media_type: String, - pub config: serde_json::Value, - pub quality_profile_id: Option, - pub media_library_folder_id: Option, - pub monitored: bool, - pub enabled: bool, - pub poll_interval_secs: i32, -} - -// ── Input types ──────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateImportListInput { - pub name: String, - pub list_type: String, - pub media_type: String, - pub config: serde_json::Value, - pub quality_profile_id: Option, - pub media_library_folder_id: Option, - #[serde(default = "default_true")] - pub monitored: bool, - #[serde(default = "default_true")] - pub enabled: bool, - #[serde(default = "default_poll_interval")] - pub poll_interval_secs: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateImportListInput { - pub name: Option, - pub list_type: Option, - pub media_type: Option, - pub config: Option, - pub quality_profile_id: Option>, - pub media_library_folder_id: Option>, - pub monitored: Option, - pub enabled: Option, - pub poll_interval_secs: Option, -} - -fn default_true() -> bool { - true -} - -fn default_poll_interval() -> i32 { - 3600 -} - -// ── Sync result ──────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportListSyncResult { - pub list_id: i64, - pub list_name: String, - pub items_found: usize, - pub items_added: usize, - pub items_existing: usize, - pub errors: Vec, -} - -// ── Internal item from a list source ─────────────────────────────────────── - -struct FetchedItem { - title: String, - tmdb_id: i64, - year: Option, - overview: Option, -} - -// ── Service ──────────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct ImportListService { - pool: PgPool, -} - -impl ImportListService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn list(&self) -> Result> { - let rows = sqlx::query_as::<_, ImportList>( - "SELECT * FROM import_lists ORDER BY id", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, ImportList>( - "SELECT * FROM import_lists WHERE id = $1", - ) - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn create(&self, input: CreateImportListInput) -> Result { - let row = sqlx::query_as::<_, ImportList>( - "INSERT INTO import_lists (name, list_type, media_type, config, quality_profile_id, media_library_folder_id, monitored, enabled, poll_interval_secs) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *", - ) - .bind(&input.name) - .bind(&input.list_type) - .bind(&input.media_type) - .bind(&input.config) - .bind(input.quality_profile_id) - .bind(input.media_library_folder_id) - .bind(input.monitored) - .bind(input.enabled) - .bind(input.poll_interval_secs) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn update(&self, id: i64, input: UpdateImportListInput) -> Result { - let existing = self.get(id).await?; - let name = input.name.unwrap_or(existing.name); - let list_type = input.list_type.unwrap_or(existing.list_type); - let media_type = input.media_type.unwrap_or(existing.media_type); - let config = input.config.unwrap_or(existing.config); - let quality_profile_id = input - .quality_profile_id - .unwrap_or(existing.quality_profile_id); - let media_library_folder_id = input.media_library_folder_id.unwrap_or(existing.media_library_folder_id); - let monitored = input.monitored.unwrap_or(existing.monitored); - let enabled = input.enabled.unwrap_or(existing.enabled); - let poll_interval_secs = input - .poll_interval_secs - .unwrap_or(existing.poll_interval_secs); - - let row = sqlx::query_as::<_, ImportList>( - "UPDATE import_lists - SET name = $1, list_type = $2, media_type = $3, config = $4, - quality_profile_id = $5, media_library_folder_id = $6, monitored = $7, - enabled = $8, poll_interval_secs = $9 - WHERE id = $10 - RETURNING *", - ) - .bind(&name) - .bind(&list_type) - .bind(&media_type) - .bind(&config) - .bind(quality_profile_id) - .bind(media_library_folder_id) - .bind(monitored) - .bind(enabled) - .bind(poll_interval_secs) - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn delete(&self, id: i64) -> Result<()> { - sqlx::query("DELETE FROM import_lists WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Sync a single list -- fetch items from source, add missing media to library. - pub async fn sync( - &self, - id: i64, - tmdb_client: &TmdbClient, - ) -> Result { - let list = self.get(id).await?; - let mut result = ImportListSyncResult { - list_id: list.id, - list_name: list.name.clone(), - items_found: 0, - items_added: 0, - items_existing: 0, - errors: Vec::new(), - }; - - // Fetch items from the external source - let items = match fetch_list_items(&list, tmdb_client).await { - Ok(items) => items, - Err(e) => { - result.errors.push(format!("Failed to fetch list: {e}")); - return Ok(result); - } - }; - - result.items_found = items.len(); - - // Resolve media library folder path for building media paths - let media_library_folder_path = if let Some(rf_id) = list.media_library_folder_id { - let row: Option<(String,)> = - sqlx::query_as("SELECT path FROM media_library_folders WHERE id = $1") - .bind(rf_id) - .fetch_optional(&self.pool) - .await?; - row.map(|(p,)| p) - } else { - None - }; - - let quality_profile_id = list.quality_profile_id.unwrap_or(1); - - for item in &items { - match list.media_type.as_str() { - "movie" => { - // Check if already exists by tmdb_id - let exists: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM movies WHERE tmdb_id = $1", - ) - .bind(item.tmdb_id) - .fetch_optional(&self.pool) - .await?; - - if exists.is_some() { - result.items_existing += 1; - continue; - } - - let clean = stackarr_parser::clean_title(&item.title); - let path = match &media_library_folder_path { - Some(rf) => format!("{rf}/{} ({year})", item.title, year = item.year.unwrap_or(0)), - None => item.title.clone(), - }; - - match sqlx::query( - "INSERT INTO movies (title, clean_title, sort_title, path, quality_profile_id, monitored, tmdb_id, year, overview) - VALUES ($1, $2, $2, $3, $4, $5, $6, $7, $8)", - ) - .bind(&item.title) - .bind(&clean) - .bind(&path) - .bind(quality_profile_id) - .bind(list.monitored) - .bind(item.tmdb_id) - .bind(item.year) - .bind(&item.overview) - .execute(&self.pool) - .await - { - Ok(_) => result.items_added += 1, - Err(e) => result.errors.push(format!( - "Failed to insert movie '{}': {e}", - item.title - )), - } - } - "series" => { - // Check if already exists by tmdb_id - let exists: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM series WHERE tmdb_id = $1", - ) - .bind(item.tmdb_id) - .fetch_optional(&self.pool) - .await?; - - if exists.is_some() { - result.items_existing += 1; - continue; - } - - let clean = stackarr_parser::clean_title(&item.title); - let path = match &media_library_folder_path { - Some(rf) => format!("{rf}/{}", item.title), - None => item.title.clone(), - }; - - match sqlx::query( - "INSERT INTO series (title, clean_title, sort_title, path, quality_profile_id, monitored, tmdb_id, overview) - VALUES ($1, $2, $2, $3, $4, $5, $6, $7)", - ) - .bind(&item.title) - .bind(&clean) - .bind(&path) - .bind(quality_profile_id) - .bind(list.monitored) - .bind(item.tmdb_id) - .bind(&item.overview) - .execute(&self.pool) - .await - { - Ok(_) => result.items_added += 1, - Err(e) => result.errors.push(format!( - "Failed to insert series '{}': {e}", - item.title - )), - } - } - other => { - result - .errors - .push(format!("Unsupported media_type: {other}")); - break; - } - } - } - - Ok(result) - } - - /// Sync all enabled lists. - pub async fn sync_all( - &self, - tmdb_client: &TmdbClient, - ) -> Result> { - let lists = sqlx::query_as::<_, ImportList>( - "SELECT * FROM import_lists WHERE enabled = true ORDER BY id", - ) - .fetch_all(&self.pool) - .await?; - - let mut results = Vec::with_capacity(lists.len()); - for list in &lists { - match self.sync(list.id, tmdb_client).await { - Ok(r) => results.push(r), - Err(e) => results.push(ImportListSyncResult { - list_id: list.id, - list_name: list.name.clone(), - items_found: 0, - items_added: 0, - items_existing: 0, - errors: vec![format!("Sync failed: {e}")], - }), - } - } - Ok(results) - } -} - -// ── Fetch items from an external source ──────────────────────────────────── - -async fn fetch_list_items( - list: &ImportList, - tmdb_client: &TmdbClient, -) -> Result> { - match list.list_type.as_str() { - "tmdb_popular" => fetch_tmdb_popular(list, tmdb_client).await, - "tmdb_trending" => fetch_tmdb_trending(list, tmdb_client).await, - "trakt_watchlist" => fetch_trakt_watchlist(list).await, - "imdb_list" => fetch_imdb_list(list).await, - other => anyhow::bail!("Unknown list type: {other}"), - } -} - -// ── TMDB Popular ─────────────────────────────────────────────────────────── - -/// Response shape for TMDB paginated results (movies and TV). -#[derive(Debug, Deserialize)] -struct TmdbPagedResponse { - results: Vec, -} - -#[derive(Debug, Deserialize)] -struct TmdbListItem { - id: i64, - // Movie uses "title", TV uses "name" - #[serde(alias = "name")] - title: Option, - overview: Option, - // Movie uses "release_date", TV uses "first_air_date" - release_date: Option, - first_air_date: Option, - #[serde(default)] - vote_average: f64, - #[serde(default)] - vote_count: i64, -} - -async fn fetch_tmdb_popular( - list: &ImportList, - tmdb_client: &TmdbClient, -) -> Result> { - let min_vote_avg = list - .config - .get("min_vote_average") - .and_then(|v| v.as_f64()) - .unwrap_or(0.0); - let min_vote_count = list - .config - .get("min_vote_count") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - let endpoint = match list.media_type.as_str() { - "movie" => "movie/popular", - "series" => "tv/popular", - _ => anyhow::bail!("Unsupported media_type for tmdb_popular: {}", list.media_type), - }; - - // Fetch first page - let url = format!( - "https://api.themoviedb.org/3/{endpoint}?api_key={key}&page=1", - key = tmdb_api_key(tmdb_client)?, - ); - let resp: TmdbPagedResponse = reqwest::get(&url) - .await - .context("TMDB popular request failed")? - .error_for_status() - .context("TMDB popular returned error")? - .json() - .await - .context("Failed to parse TMDB popular response")?; - - let items = resp - .results - .into_iter() - .filter(|r| r.vote_average >= min_vote_avg && r.vote_count >= min_vote_count) - .map(|r| tmdb_item_to_fetched(&r, &list.media_type)) - .collect(); - - Ok(items) -} - -// ── TMDB Trending ────────────────────────────────────────────────────────── - -async fn fetch_tmdb_trending( - list: &ImportList, - tmdb_client: &TmdbClient, -) -> Result> { - let time_window = list - .config - .get("time_window") - .and_then(|v| v.as_str()) - .unwrap_or("week"); - - let media = match list.media_type.as_str() { - "movie" => "movie", - "series" => "tv", - _ => anyhow::bail!("Unsupported media_type for tmdb_trending: {}", list.media_type), - }; - - let url = format!( - "https://api.themoviedb.org/3/trending/{media}/{time_window}?api_key={key}", - key = tmdb_api_key(tmdb_client)?, - ); - let resp: TmdbPagedResponse = reqwest::get(&url) - .await - .context("TMDB trending request failed")? - .error_for_status() - .context("TMDB trending returned error")? - .json() - .await - .context("Failed to parse TMDB trending response")?; - - let items = resp - .results - .into_iter() - .map(|r| tmdb_item_to_fetched(&r, &list.media_type)) - .collect(); - - Ok(items) -} - -// ── Trakt Watchlist (stub) ───────────────────────────────────────────────── - -async fn fetch_trakt_watchlist(_list: &ImportList) -> Result> { - tracing::info!("Trakt watchlist import is a stub -- returning empty results"); - Ok(Vec::new()) -} - -// ── IMDB List (stub) ─────────────────────────────────────────────────────── - -async fn fetch_imdb_list(_list: &ImportList) -> Result> { - tracing::info!("IMDB list import is a stub -- returning empty results"); - Ok(Vec::new()) -} - -// ── Helpers ──────────────────────────────────────────────────────────────── - -/// Extract the API key from the TmdbClient by reading the env var -/// (the client itself doesn't expose the key, so we read the same env). -fn tmdb_api_key(_client: &TmdbClient) -> Result { - std::env::var("STACKARR_TMDB_API_KEY") - .context("STACKARR_TMDB_API_KEY not set -- cannot fetch import lists from TMDB") -} - -fn tmdb_item_to_fetched(item: &TmdbListItem, media_type: &str) -> FetchedItem { - let title = item.title.clone().unwrap_or_default(); - let date_str = match media_type { - "series" => item.first_air_date.as_deref(), - _ => item.release_date.as_deref(), - }; - let year = date_str - .and_then(|d| d.get(..4)) - .and_then(|y| y.parse::().ok()); - - FetchedItem { - title, - tmdb_id: item.id, - year, - overview: item.overview.clone(), - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/src/lib.rs deleted file mode 100644 index 771d35fe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-media/src/lib.rs +++ /dev/null @@ -1,901 +0,0 @@ -pub mod import_lists; - -use anyhow::Result; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -use stackarr_core::models::{Episode, Movie, Series}; - -// ── Input types ───────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateSeriesInput { - pub title: String, - pub path: String, - pub quality_profile_id: i32, - #[serde(default)] - pub monitored: bool, - pub tvdb_id: Option, - pub tmdb_id: Option, - pub imdb_id: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateSeriesInput { - pub title: Option, - pub path: Option, - pub quality_profile_id: Option, - pub monitored: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateMovieInput { - pub title: String, - pub path: String, - pub quality_profile_id: i32, - #[serde(default)] - pub monitored: bool, - pub tmdb_id: Option, - pub imdb_id: Option, - pub year: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateMovieInput { - pub title: Option, - pub path: Option, - pub quality_profile_id: Option, - pub monitored: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateEpisodeInput { - pub series_id: i64, - pub season_number: i32, - pub episode_number: i32, - pub title: Option, - #[serde(default = "default_true")] - pub monitored: bool, -} - -fn default_true() -> bool { - true -} - -// ── Series service ────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct SeriesService { - pool: PgPool, -} - -impl SeriesService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn list(&self) -> Result> { - let rows = sqlx::query_as::<_, Series>("SELECT * FROM series ORDER BY sort_title") - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, Series>("SELECT * FROM series WHERE id = $1") - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn create(&self, input: CreateSeriesInput) -> Result { - let clean = stackarr_parser::clean_title(&input.title); - let sort = clean.clone(); - let row = sqlx::query_as::<_, Series>( - "INSERT INTO series (title, clean_title, sort_title, path, quality_profile_id, monitored, tvdb_id, tmdb_id, imdb_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *", - ) - .bind(&input.title) - .bind(&clean) - .bind(&sort) - .bind(&input.path) - .bind(input.quality_profile_id) - .bind(input.monitored) - .bind(input.tvdb_id) - .bind(input.tmdb_id) - .bind(&input.imdb_id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn update(&self, id: i64, input: UpdateSeriesInput) -> Result { - // Fetch-then-update pattern for partial updates - let existing = self.get(id).await?; - let title = input.title.unwrap_or(existing.title); - let path = input.path.unwrap_or(existing.path); - let qp = input.quality_profile_id.unwrap_or(existing.quality_profile_id); - let monitored = input.monitored.unwrap_or(existing.monitored); - let clean = stackarr_parser::clean_title(&title); - - let row = sqlx::query_as::<_, Series>( - "UPDATE series SET title = $1, clean_title = $2, sort_title = $2, path = $3, quality_profile_id = $4, monitored = $5 - WHERE id = $6 RETURNING *", - ) - .bind(&title) - .bind(&clean) - .bind(&path) - .bind(qp) - .bind(monitored) - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn delete(&self, id: i64) -> Result<()> { - sqlx::query("DELETE FROM series WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -// ── Movie service ─────────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct MovieService { - pool: PgPool, -} - -impl MovieService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn list(&self) -> Result> { - let rows = sqlx::query_as::<_, Movie>("SELECT * FROM movies ORDER BY sort_title") - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, Movie>("SELECT * FROM movies WHERE id = $1") - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn create(&self, input: CreateMovieInput) -> Result { - let clean = stackarr_parser::clean_title(&input.title); - let sort = clean.clone(); - let row = sqlx::query_as::<_, Movie>( - "INSERT INTO movies (title, clean_title, sort_title, path, quality_profile_id, monitored, tmdb_id, imdb_id, year) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING *", - ) - .bind(&input.title) - .bind(&clean) - .bind(&sort) - .bind(&input.path) - .bind(input.quality_profile_id) - .bind(input.monitored) - .bind(input.tmdb_id) - .bind(&input.imdb_id) - .bind(input.year) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn update(&self, id: i64, input: UpdateMovieInput) -> Result { - let existing = self.get(id).await?; - let title = input.title.unwrap_or(existing.title); - let path = input.path.unwrap_or(existing.path); - let qp = input.quality_profile_id.unwrap_or(existing.quality_profile_id); - let monitored = input.monitored.unwrap_or(existing.monitored); - let clean = stackarr_parser::clean_title(&title); - - let row = sqlx::query_as::<_, Movie>( - "UPDATE movies SET title = $1, clean_title = $2, sort_title = $2, path = $3, quality_profile_id = $4, monitored = $5 - WHERE id = $6 RETURNING *", - ) - .bind(&title) - .bind(&clean) - .bind(&path) - .bind(qp) - .bind(monitored) - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn delete(&self, id: i64) -> Result<()> { - sqlx::query("DELETE FROM movies WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -// ── Episode service ───────────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct EpisodeService { - pool: PgPool, -} - -impl EpisodeService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn list_by_series(&self, series_id: i64) -> Result> { - let rows = sqlx::query_as::<_, Episode>( - "SELECT * FROM episodes WHERE series_id = $1 ORDER BY season_number, episode_number", - ) - .bind(series_id) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } - - pub async fn get(&self, id: i64) -> Result { - let row = sqlx::query_as::<_, Episode>("SELECT * FROM episodes WHERE id = $1") - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn create(&self, input: CreateEpisodeInput) -> Result { - let row = sqlx::query_as::<_, Episode>( - "INSERT INTO episodes (series_id, season_number, episode_number, title, monitored) - VALUES ($1, $2, $3, $4, $5) RETURNING *", - ) - .bind(input.series_id) - .bind(input.season_number) - .bind(input.episode_number) - .bind(&input.title) - .bind(input.monitored) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - pub async fn set_monitored(&self, id: i64, monitored: bool) -> Result<()> { - sqlx::query("UPDATE episodes SET monitored = $1 WHERE id = $2") - .bind(monitored) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Update episode monitored status and return the updated episode. - pub async fn update_monitored(&self, id: i64, monitored: bool) -> Result { - let row = sqlx::query_as::<_, Episode>( - "UPDATE episodes SET monitored = $1 WHERE id = $2 RETURNING *", - ) - .bind(monitored) - .bind(id) - .fetch_one(&self.pool) - .await?; - Ok(row) - } - - /// Bulk update monitored status for all episodes in a season. - pub async fn set_season_monitored( - &self, - series_id: i64, - season_number: i32, - monitored: bool, - ) -> Result<()> { - sqlx::query( - "UPDATE episodes SET monitored = $1 WHERE series_id = $2 AND season_number = $3", - ) - .bind(monitored) - .bind(series_id) - .bind(season_number) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Bulk update monitored status for multiple episode IDs. - pub async fn set_bulk_monitored(&self, episode_ids: &[i64], monitored: bool) -> Result<()> { - if episode_ids.is_empty() { - return Ok(()); - } - sqlx::query("UPDATE episodes SET monitored = $1 WHERE id = ANY($2)") - .bind(monitored) - .bind(episode_ids) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -// ── Calendar service ─────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -pub struct CalendarEntry { - pub episode_id: i64, - pub series_id: i64, - pub series_title: String, - pub season_number: i32, - pub episode_number: i32, - pub episode_title: Option, - pub air_date_utc: Option>, - pub has_file: bool, - pub monitored: bool, -} - -#[derive(Clone)] -pub struct CalendarService { - pool: PgPool, -} - -impl CalendarService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Get upcoming episodes between two dates (inclusive). - pub async fn get_calendar(&self, start: &str, end: &str) -> Result> { - let rows = sqlx::query_as::<_, CalendarEntry>( - "SELECT e.id AS episode_id, e.series_id, s.title AS series_title, - e.season_number, e.episode_number, e.title AS episode_title, - e.air_date_utc, (e.episode_file_id IS NOT NULL) AS has_file, - e.monitored - FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.air_date_utc BETWEEN $1::timestamptz AND $2::timestamptz - AND s.monitored = true - ORDER BY e.air_date_utc", - ) - .bind(start) - .bind(end) - .fetch_all(&self.pool) - .await?; - Ok(rows) - } -} - -// ── Wanted service ───────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WantedPage { - pub page: i64, - pub page_size: i64, - pub total_records: i64, - pub records: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct WantedRecord { - pub id: i64, - pub media_type: String, - pub media_id: i64, - pub title: String, - pub episode_info: Option, - pub quality_profile_id: i32, - pub air_date: Option, - pub monitored: bool, -} - -#[derive(Clone)] -pub struct WantedService { - pool: PgPool, -} - -impl WantedService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Missing: monitored episodes/movies without a file, aired in the past. - pub async fn missing(&self, page: i64, page_size: i64) -> Result { - let offset = (page - 1).max(0) * page_size; - - // Count total missing episodes - let episode_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NULL - AND e.air_date_utc < NOW()", - ) - .fetch_one(&self.pool) - .await?; - - // Count total missing movies - let movie_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM movies m - WHERE m.monitored = true AND m.movie_file_id IS NULL", - ) - .fetch_one(&self.pool) - .await?; - - let total_records = episode_count.0 + movie_count.0; - - // Fetch combined missing records using a UNION query - let rows = sqlx::query_as::<_, (i64, String, i64, String, Option, i32, Option, bool)>( - "SELECT * FROM ( - SELECT e.id, 'series'::text AS media_type, e.series_id AS media_id, - s.title, CONCAT('S', LPAD(e.season_number::text, 2, '0'), 'E', LPAD(e.episode_number::text, 2, '0')) AS episode_info, - s.quality_profile_id, e.air_date_utc::text AS air_date, e.monitored - FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NULL - AND e.air_date_utc < NOW() - UNION ALL - SELECT m.id, 'movie'::text AS media_type, m.id AS media_id, - m.title, NULL AS episode_info, - m.quality_profile_id, m.physical_release::text AS air_date, m.monitored - FROM movies m - WHERE m.monitored = true AND m.movie_file_id IS NULL - ) combined - ORDER BY air_date DESC NULLS LAST - LIMIT $1 OFFSET $2", - ) - .bind(page_size) - .bind(offset) - .fetch_all(&self.pool) - .await?; - - let records = rows - .into_iter() - .map(|(id, media_type, media_id, title, episode_info, quality_profile_id, air_date, monitored)| { - WantedRecord { - id, - media_type, - media_id, - title, - episode_info, - quality_profile_id, - air_date, - monitored, - } - }) - .collect(); - - Ok(WantedPage { - page, - page_size, - total_records, - records, - }) - } - - /// Cutoff unmet: items that have a file but below quality profile cutoff. - /// Simplified stub — real cutoff comparison will come in Phase 4 with the - /// decision engine. For now returns items with files as candidates. - pub async fn cutoff_unmet(&self, page: i64, page_size: i64) -> Result { - let offset = (page - 1).max(0) * page_size; - - // Count episodes with files (candidates for cutoff check) - let episode_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NOT NULL", - ) - .fetch_one(&self.pool) - .await?; - - // Count movies with files - let movie_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM movies m - WHERE m.monitored = true AND m.movie_file_id IS NOT NULL", - ) - .fetch_one(&self.pool) - .await?; - - let total_records = episode_count.0 + movie_count.0; - - let rows = sqlx::query_as::<_, (i64, String, i64, String, Option, i32, Option, bool)>( - "SELECT * FROM ( - SELECT e.id, 'series'::text AS media_type, e.series_id AS media_id, - s.title, CONCAT('S', LPAD(e.season_number::text, 2, '0'), 'E', LPAD(e.episode_number::text, 2, '0')) AS episode_info, - s.quality_profile_id, e.air_date_utc::text AS air_date, e.monitored - FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NOT NULL - UNION ALL - SELECT m.id, 'movie'::text AS media_type, m.id AS media_id, - m.title, NULL AS episode_info, - m.quality_profile_id, m.physical_release::text AS air_date, m.monitored - FROM movies m - WHERE m.monitored = true AND m.movie_file_id IS NOT NULL - ) combined - ORDER BY air_date DESC NULLS LAST - LIMIT $1 OFFSET $2", - ) - .bind(page_size) - .bind(offset) - .fetch_all(&self.pool) - .await?; - - let records = rows - .into_iter() - .map(|(id, media_type, media_id, title, episode_info, quality_profile_id, air_date, monitored)| { - WantedRecord { - id, - media_type, - media_id, - title, - episode_info, - quality_profile_id, - air_date, - monitored, - } - }) - .collect(); - - Ok(WantedPage { - page, - page_size, - total_records, - records, - }) - } -} - -// ── Metadata refresh service ─────────────────────────────────────────────── - -#[derive(Clone)] -pub struct MetadataRefreshService { - pool: PgPool, -} - -impl MetadataRefreshService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Find series that haven't been synced in over 12 hours. - pub async fn find_stale_series(&self) -> Result> { - let rows: Vec<(i64,)> = sqlx::query_as( - "SELECT id FROM series - WHERE last_info_sync IS NULL - OR last_info_sync < NOW() - INTERVAL '12 hours'", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(|(id,)| id).collect()) - } - - /// Find movies that haven't been synced in over 12 hours. - pub async fn find_stale_movies(&self) -> Result> { - let rows: Vec<(i64,)> = sqlx::query_as( - "SELECT id FROM movies - WHERE last_info_sync IS NULL - OR last_info_sync < NOW() - INTERVAL '12 hours'", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows.into_iter().map(|(id,)| id).collect()) - } - - /// Update last_info_sync timestamp for a series. - pub async fn mark_series_synced(&self, id: i64) -> Result<()> { - sqlx::query("UPDATE series SET last_info_sync = NOW() WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Update last_info_sync timestamp for a movie. - pub async fn mark_movie_synced(&self, id: i64) -> Result<()> { - sqlx::query("UPDATE movies SET last_info_sync = NOW() WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Update series metadata from TMDB data. - pub async fn update_series_metadata( - &self, - id: i64, - overview: Option<&str>, - status: &str, - network: Option<&str>, - runtime: Option, - images: Option<&serde_json::Value>, - genres: Option<&[String]>, - ) -> Result<()> { - sqlx::query( - "UPDATE series - SET overview = COALESCE($1, overview), - network = COALESCE($2, network), - runtime = COALESCE($3, runtime), - images = COALESCE($4, images), - genres = COALESCE($5, genres), - last_info_sync = NOW() - WHERE id = $6", - ) - .bind(overview) - .bind(network) - .bind(runtime) - .bind(images) - .bind(genres) - .bind(id) - .execute(&self.pool) - .await?; - // Note: status is not updated here because the DB column uses the - // SeriesStatus enum type — mapping the TMDB string to that enum is - // deferred to a later phase. - let _ = status; - Ok(()) - } - - /// Update movie metadata from TMDB data. - pub async fn update_movie_metadata( - &self, - id: i64, - overview: Option<&str>, - studio: Option<&str>, - images: Option<&serde_json::Value>, - genres: Option<&[String]>, - ) -> Result<()> { - sqlx::query( - "UPDATE movies - SET overview = COALESCE($1, overview), - studio = COALESCE($2, studio), - images = COALESCE($3, images), - genres = COALESCE($4, genres), - last_info_sync = NOW() - WHERE id = $5", - ) - .bind(overview) - .bind(studio) - .bind(images) - .bind(genres) - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use stackarr_core::test_helpers::{TestDb, seed_quality_profile, seed_media_library_folder, seed_series, seed_episode}; - - // ── SeriesService ─────────────────────────────────────────────────── - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_series_create_and_get() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - - let svc = SeriesService::new(db.pool.clone()); - let created = svc.create(CreateSeriesInput { - title: "Breaking Bad".into(), - path: "/tv/Breaking Bad".into(), - quality_profile_id: profile_id, - monitored: true, - tvdb_id: Some(81189), - tmdb_id: Some(1396), - imdb_id: Some("tt0903747".into()), - }).await.expect("create series"); - - assert_eq!(created.title, "Breaking Bad"); - assert_eq!(created.tvdb_id, Some(81189)); - - let fetched = svc.get(created.id).await.expect("get series"); - assert_eq!(fetched.title, "Breaking Bad"); - assert_eq!(fetched.quality_profile_id, profile_id); - - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_series_list() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - let rf = seed_media_library_folder(&db.pool, "/tv", "series").await; - seed_series(&db.pool, "Alpha", profile_id, rf).await; - seed_series(&db.pool, "Beta", profile_id, rf).await; - seed_series(&db.pool, "Gamma", profile_id, rf).await; - - let svc = SeriesService::new(db.pool.clone()); - let list = svc.list().await.expect("list series"); - assert_eq!(list.len(), 3); - - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_series_update_partial() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - - let svc = SeriesService::new(db.pool.clone()); - let created = svc.create(CreateSeriesInput { - title: "Original Title".into(), - path: "/tv/Original".into(), - quality_profile_id: profile_id, - monitored: true, - tvdb_id: None, - tmdb_id: None, - imdb_id: None, - }).await.expect("create"); - - let updated = svc.update(created.id, UpdateSeriesInput { - title: Some("New Title".into()), - path: None, - quality_profile_id: None, - monitored: None, - }).await.expect("update"); - - assert_eq!(updated.title, "New Title"); - assert_eq!(updated.path, "/tv/Original"); // unchanged - - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_series_delete() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - - let svc = SeriesService::new(db.pool.clone()); - let created = svc.create(CreateSeriesInput { - title: "To Delete".into(), - path: "/tv/del".into(), - quality_profile_id: profile_id, - monitored: false, - tvdb_id: None, - tmdb_id: None, - imdb_id: None, - }).await.expect("create"); - - svc.delete(created.id).await.expect("delete"); - let result = svc.get(created.id).await; - assert!(result.is_err()); - - db.cleanup().await; - } - - // ── MovieService ──────────────────────────────────────────────────── - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_movie_create_and_get() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - - let svc = MovieService::new(db.pool.clone()); - let created = svc.create(CreateMovieInput { - title: "Inception".into(), - path: "/movies/Inception (2010)".into(), - quality_profile_id: profile_id, - monitored: true, - tmdb_id: Some(27205), - imdb_id: Some("tt1375666".into()), - year: Some(2010), - }).await.expect("create movie"); - - assert_eq!(created.title, "Inception"); - - let fetched = svc.get(created.id).await.expect("get movie"); - assert_eq!(fetched.tmdb_id, Some(27205)); - - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_movie_delete() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - - let svc = MovieService::new(db.pool.clone()); - let created = svc.create(CreateMovieInput { - title: "To Delete".into(), - path: "/movies/del".into(), - quality_profile_id: profile_id, - monitored: false, - tmdb_id: None, - imdb_id: None, - year: None, - }).await.expect("create"); - - svc.delete(created.id).await.expect("delete"); - let result = svc.get(created.id).await; - assert!(result.is_err()); - - db.cleanup().await; - } - - // ── EpisodeService ────────────────────────────────────────────────── - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_episode_create_and_list() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - let rf = seed_media_library_folder(&db.pool, "/tv", "series").await; - let series_id = seed_series(&db.pool, "Test Series", profile_id, rf).await; - seed_episode(&db.pool, series_id, 1, 1).await; - seed_episode(&db.pool, series_id, 1, 2).await; - seed_episode(&db.pool, series_id, 1, 3).await; - - let svc = EpisodeService::new(db.pool.clone()); - let episodes = svc.list_by_series(series_id).await.expect("list episodes"); - assert_eq!(episodes.len(), 3); - - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_episode_set_monitored() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - let rf = seed_media_library_folder(&db.pool, "/tv", "series").await; - let series_id = seed_series(&db.pool, "Test", profile_id, rf).await; - let ep_id = seed_episode(&db.pool, series_id, 1, 1).await; - - let svc = EpisodeService::new(db.pool.clone()); - - // Start monitored, toggle off - svc.set_monitored(ep_id, false).await.expect("set monitored false"); - let ep = svc.get(ep_id).await.expect("get episode"); - assert!(!ep.monitored); - - // Toggle back on - svc.set_monitored(ep_id, true).await.expect("set monitored true"); - let ep = svc.get(ep_id).await.expect("get episode"); - assert!(ep.monitored); - - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_episode_bulk_monitored() { - let db = TestDb::new().await; - let profile_id = seed_quality_profile(&db.pool).await; - let rf = seed_media_library_folder(&db.pool, "/tv", "series").await; - let series_id = seed_series(&db.pool, "Bulk", profile_id, rf).await; - let ep1 = seed_episode(&db.pool, series_id, 1, 1).await; - let ep2 = seed_episode(&db.pool, series_id, 1, 2).await; - let ep3 = seed_episode(&db.pool, series_id, 1, 3).await; - - let svc = EpisodeService::new(db.pool.clone()); - svc.set_bulk_monitored(&[ep1, ep2, ep3], false).await.expect("bulk unmonitor"); - - let episodes = svc.list_by_series(series_id).await.expect("list"); - assert!(episodes.iter().all(|e| !e.monitored)); - - db.cleanup().await; - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-metadata/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-metadata/Cargo.toml deleted file mode 100644 index fdf56bf8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-metadata/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "stackarr-metadata" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -reqwest = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -leaky-bucket = { workspace = true } -lru = { workspace = true } -parking_lot = { workspace = true } - -[dev-dependencies] -wiremock = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-metadata/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-metadata/src/lib.rs deleted file mode 100644 index 318cd617..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-metadata/src/lib.rs +++ /dev/null @@ -1,838 +0,0 @@ -use std::num::NonZeroUsize; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use chrono::NaiveDate; -use leaky_bucket::RateLimiter; -use lru::LruCache; -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; - -// ── Cache types ───────────────────────────────────────────────────────────── - -/// TTL categories for cached responses. -#[derive(Clone, Copy)] -enum CacheTtl { - /// Search and list results: 1 hour. - Search, - /// Detail/single-resource fetches: 24 hours. - Detail, -} - -impl CacheTtl { - fn duration(self) -> Duration { - match self { - Self::Search => Duration::from_secs(60 * 60), // 1 hour - Self::Detail => Duration::from_secs(60 * 60 * 24), // 24 hours - } - } -} - -struct CacheEntry { - value: serde_json::Value, - expires_at: Instant, -} - -/// Client for The Movie Database (TMDB) API. -pub struct TmdbClient { - client: reqwest::Client, - api_key: String, - base_url: String, - limiter: Arc, - cache: Arc>>, -} - -// ── Result types ──────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbSearchResults { - pub page: i64, - pub total_pages: i64, - pub total_results: i64, - pub results: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbSeries { - pub id: i64, - pub name: String, - #[serde(default)] - pub overview: Option, - pub first_air_date: Option, - #[serde(default)] - pub poster_path: Option, - #[serde(default)] - pub backdrop_path: Option, - #[serde(default)] - pub genre_ids: Vec, - #[serde(default)] - pub vote_average: f64, - #[serde(default)] - pub popularity: f64, - #[serde(default)] - pub vote_count: i64, - #[serde(default)] - pub original_language: Option, -} - -/// A multi-result from TMDB trending that can be either a movie or TV show. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbTrendingItem { - pub id: i64, - pub media_type: String, // "movie" or "tv" - #[serde(default)] - pub title: Option, - #[serde(default)] - pub name: Option, - #[serde(default)] - pub overview: Option, - pub release_date: Option, - pub first_air_date: Option, - #[serde(default)] - pub poster_path: Option, - #[serde(default)] - pub backdrop_path: Option, - #[serde(default)] - pub genre_ids: Vec, - #[serde(default)] - pub vote_average: f64, - #[serde(default)] - pub vote_count: i64, - #[serde(default)] - pub popularity: f64, - #[serde(default)] - pub original_language: Option, -} - -impl TmdbTrendingItem { - /// Unified display title (movie uses `title`, TV uses `name`). - pub fn display_title(&self) -> &str { - self.title - .as_deref() - .or(self.name.as_deref()) - .unwrap_or("Unknown") - } -} - -/// Language entry from TMDB /configuration/languages. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbLanguage { - pub iso_639_1: String, - pub english_name: String, - #[serde(default)] - pub name: String, -} - -/// Keyword entry from TMDB. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbKeyword { - pub id: i64, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbSeriesDetail { - pub id: i64, - pub name: String, - pub overview: Option, - pub first_air_date: Option, - pub status: Option, - pub number_of_seasons: Option, - pub number_of_episodes: Option, - #[serde(default)] - pub episode_run_time: Vec, - pub poster_path: Option, - pub backdrop_path: Option, - #[serde(default)] - pub genres: Vec, - #[serde(default)] - pub networks: Vec, - pub external_ids: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbMovie { - pub id: i64, - pub title: String, - #[serde(default)] - pub overview: Option, - pub release_date: Option, - #[serde(default)] - pub poster_path: Option, - #[serde(default)] - pub backdrop_path: Option, - #[serde(default)] - pub genre_ids: Vec, - #[serde(default)] - pub vote_average: f64, - #[serde(default)] - pub vote_count: i64, - #[serde(default)] - pub popularity: f64, - #[serde(default)] - pub original_language: Option, -} - -// ── Discovery filter parameters ───────────────────────────────────────────── - -/// Filters for the TMDB `/discover/movie` and `/discover/tv` endpoints. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DiscoverFilters { - pub page: Option, - pub sort_by: Option, - pub with_genres: Option, - pub without_genres: Option, - pub with_keywords: Option, - pub without_keywords: Option, - pub with_companies: Option, - pub with_networks: Option, - pub with_watch_providers: Option, - pub watch_region: Option, - pub with_original_language: Option, - pub primary_release_date_gte: Option, - pub primary_release_date_lte: Option, - pub first_air_date_gte: Option, - pub first_air_date_lte: Option, - pub with_runtime_gte: Option, - pub with_runtime_lte: Option, - pub vote_average_gte: Option, - pub vote_average_lte: Option, - pub vote_count_gte: Option, - pub vote_count_lte: Option, - pub with_status: Option, - pub certification: Option, - pub certification_country: Option, - pub language: Option, -} - -impl DiscoverFilters { - /// Build query string parameters for TMDB discover endpoint. - fn to_query_pairs(&self) -> Vec<(&str, String)> { - let mut pairs = Vec::new(); - if let Some(ref v) = self.page { pairs.push(("page", v.to_string())); } - if let Some(ref v) = self.sort_by { pairs.push(("sort_by", v.clone())); } - if let Some(ref v) = self.with_genres { pairs.push(("with_genres", v.clone())); } - if let Some(ref v) = self.without_genres { pairs.push(("without_genres", v.clone())); } - if let Some(ref v) = self.with_keywords { pairs.push(("with_keywords", v.clone())); } - if let Some(ref v) = self.without_keywords { pairs.push(("without_keywords", v.clone())); } - if let Some(ref v) = self.with_companies { pairs.push(("with_companies", v.clone())); } - if let Some(ref v) = self.with_networks { pairs.push(("with_networks", v.clone())); } - if let Some(ref v) = self.with_watch_providers { pairs.push(("with_watch_providers", v.clone())); } - if let Some(ref v) = self.watch_region { pairs.push(("watch_region", v.clone())); } - if let Some(ref v) = self.with_original_language { pairs.push(("with_original_language", v.clone())); } - if let Some(ref v) = self.primary_release_date_gte { pairs.push(("primary_release_date.gte", v.clone())); } - if let Some(ref v) = self.primary_release_date_lte { pairs.push(("primary_release_date.lte", v.clone())); } - if let Some(ref v) = self.first_air_date_gte { pairs.push(("first_air_date.gte", v.clone())); } - if let Some(ref v) = self.first_air_date_lte { pairs.push(("first_air_date.lte", v.clone())); } - if let Some(v) = self.with_runtime_gte { pairs.push(("with_runtime.gte", v.to_string())); } - if let Some(v) = self.with_runtime_lte { pairs.push(("with_runtime.lte", v.to_string())); } - if let Some(v) = self.vote_average_gte { pairs.push(("vote_average.gte", v.to_string())); } - if let Some(v) = self.vote_average_lte { pairs.push(("vote_average.lte", v.to_string())); } - if let Some(v) = self.vote_count_gte { pairs.push(("vote_count.gte", v.to_string())); } - if let Some(v) = self.vote_count_lte { pairs.push(("vote_count.lte", v.to_string())); } - if let Some(ref v) = self.with_status { pairs.push(("with_status", v.clone())); } - if let Some(ref v) = self.certification { pairs.push(("certification", v.clone())); } - if let Some(ref v) = self.certification_country { pairs.push(("certification_country", v.clone())); } - if let Some(ref v) = self.language { pairs.push(("language", v.clone())); } - pairs - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbMovieDetail { - pub id: i64, - pub title: String, - pub overview: Option, - pub release_date: Option, - pub status: Option, - pub runtime: Option, - pub budget: Option, - pub revenue: Option, - pub poster_path: Option, - pub backdrop_path: Option, - #[serde(default)] - pub genres: Vec, - pub belongs_to_collection: Option, - pub imdb_id: Option, - #[serde(default)] - pub production_companies: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbGenre { - pub id: i64, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbNetwork { - pub id: i64, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbExternalIds { - pub tvdb_id: Option, - pub imdb_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbCollection { - pub id: i64, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbCompany { - pub id: i64, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbSeason { - pub id: i64, - pub season_number: i32, - pub name: String, - pub air_date: Option, - #[serde(default)] - pub episodes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TmdbEpisode { - pub id: i64, - pub episode_number: i32, - pub season_number: i32, - pub name: String, - pub overview: Option, - pub air_date: Option, - pub runtime: Option, -} - -// ── Client implementation ─────────────────────────────────────────────────── - -impl TmdbClient { - fn build_limiter() -> Arc { - Arc::new( - RateLimiter::builder() - .initial(4) - .max(4) - .interval(Duration::from_millis(250)) // 4 tokens/sec - .build(), - ) - } - - fn build_cache() -> Arc>> { - Arc::new(Mutex::new(LruCache::new( - NonZeroUsize::new(2000).expect("non-zero"), - ))) - } - - pub fn new(api_key: String) -> Self { - Self { - client: reqwest::Client::new(), - api_key, - base_url: "https://api.themoviedb.org/3".to_string(), - limiter: Self::build_limiter(), - cache: Self::build_cache(), - } - } - - pub fn with_base_url(api_key: String, base_url: String) -> Self { - Self { - client: reqwest::Client::new(), - api_key, - base_url, - limiter: Self::build_limiter(), - cache: Self::build_cache(), - } - } - - // ── Internal helpers ──────────────────────────────────────────────────── - - /// Check the cache for a valid (non-expired) entry. - fn cache_get(&self, key: &str) -> Option { - let mut cache = self.cache.lock(); - if let Some(entry) = cache.get(key) { - if Instant::now() < entry.expires_at { - return Some(entry.value.clone()); - } - // Expired -- remove it. - cache.pop(key); - } - None - } - - /// Store a value in the cache with the given TTL. - fn cache_put(&self, key: String, value: serde_json::Value, ttl: CacheTtl) { - let entry = CacheEntry { - value, - expires_at: Instant::now() + ttl.duration(), - }; - self.cache.lock().put(key, entry); - } - - /// Rate-limited GET that honours the cache. - async fn cached_get( - &self, - url: &str, - ttl: CacheTtl, - ) -> anyhow::Result { - // 1. Check cache - if let Some(hit) = self.cache_get(url) { - tracing::trace!("TMDB cache hit: {url}"); - return Ok(hit); - } - - // 2. Rate-limit - self.limiter.acquire_one().await; - - // 3. Fetch - let resp = self.client.get(url).send().await?.error_for_status()?; - let value: serde_json::Value = resp.json().await?; - - // 4. Store - self.cache_put(url.to_string(), value.clone(), ttl); - - Ok(value) - } - - /// Search for TV series by name. - pub async fn search_series( - &self, - query: &str, - year: Option, - ) -> anyhow::Result> { - let mut url = format!("{}/search/tv?api_key={}&query={}", self.base_url, self.api_key, query); - if let Some(y) = year { - url.push_str(&format!("&first_air_date_year={y}")); - } - tracing::debug!("TMDB search series: {query}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Search for movies by name. - pub async fn search_movie( - &self, - query: &str, - year: Option, - ) -> anyhow::Result> { - let mut url = format!("{}/search/movie?api_key={}&query={}", self.base_url, self.api_key, query); - if let Some(y) = year { - url.push_str(&format!("&year={y}")); - } - tracing::debug!("TMDB search movie: {query}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get detailed info for a TV series by TMDB id. - pub async fn get_series(&self, tmdb_id: i64) -> anyhow::Result { - let url = format!( - "{}/tv/{tmdb_id}?api_key={}&append_to_response=external_ids", - self.base_url, self.api_key - ); - tracing::debug!("TMDB get series: {tmdb_id}"); - let value = self.cached_get(&url, CacheTtl::Detail).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get detailed info for a movie by TMDB id. - pub async fn get_movie(&self, tmdb_id: i64) -> anyhow::Result { - let url = format!( - "{}/movie/{tmdb_id}?api_key={}", - self.base_url, self.api_key - ); - tracing::debug!("TMDB get movie: {tmdb_id}"); - let value = self.cached_get(&url, CacheTtl::Detail).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get season details including episodes. - pub async fn get_season( - &self, - series_tmdb_id: i64, - season_number: i32, - ) -> anyhow::Result { - let url = format!( - "{}/tv/{series_tmdb_id}/season/{season_number}?api_key={}", - self.base_url, self.api_key - ); - tracing::debug!("TMDB get season: series={series_tmdb_id} season={season_number}"); - let value = self.cached_get(&url, CacheTtl::Detail).await?; - Ok(serde_json::from_value(value)?) - } - - // ── Discovery endpoints ───────────────────────────────────────────────── - - /// Get trending media (all, movie, or tv) for a given time window. - pub async fn get_trending( - &self, - media_type: &str, // "all", "movie", or "tv" - time_window: &str, // "day" or "week" - page: Option, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!( - "{}/trending/{media_type}/{time_window}?api_key={}", - self.base_url, self.api_key - ); - if let Some(p) = page { url.push_str(&format!("&page={p}")); } - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB trending: {media_type}/{time_window}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Discover movies with advanced filters. - pub async fn discover_movies( - &self, - filters: &DiscoverFilters, - ) -> anyhow::Result> { - let mut url = format!("{}/discover/movie?api_key={}", self.base_url, self.api_key); - for (key, val) in filters.to_query_pairs() { - url.push_str(&format!("&{key}={val}")); - } - tracing::debug!("TMDB discover movies"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Discover TV shows with advanced filters. - pub async fn discover_tv( - &self, - filters: &DiscoverFilters, - ) -> anyhow::Result> { - let mut url = format!("{}/discover/tv?api_key={}", self.base_url, self.api_key); - for (key, val) in filters.to_query_pairs() { - url.push_str(&format!("&{key}={val}")); - } - tracing::debug!("TMDB discover tv"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get movie recommendations for a given movie. - pub async fn get_movie_recommendations( - &self, - movie_id: i64, - page: Option, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!( - "{}/movie/{movie_id}/recommendations?api_key={}", - self.base_url, self.api_key - ); - if let Some(p) = page { url.push_str(&format!("&page={p}")); } - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB movie recommendations: {movie_id}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get similar movies for a given movie. - pub async fn get_movie_similar( - &self, - movie_id: i64, - page: Option, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!( - "{}/movie/{movie_id}/similar?api_key={}", - self.base_url, self.api_key - ); - if let Some(p) = page { url.push_str(&format!("&page={p}")); } - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB movie similar: {movie_id}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get TV show recommendations for a given series. - pub async fn get_tv_recommendations( - &self, - tv_id: i64, - page: Option, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!( - "{}/tv/{tv_id}/recommendations?api_key={}", - self.base_url, self.api_key - ); - if let Some(p) = page { url.push_str(&format!("&page={p}")); } - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB tv recommendations: {tv_id}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get similar TV shows for a given series. - pub async fn get_tv_similar( - &self, - tv_id: i64, - page: Option, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!( - "{}/tv/{tv_id}/similar?api_key={}", - self.base_url, self.api_key - ); - if let Some(p) = page { url.push_str(&format!("&page={p}")); } - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB tv similar: {tv_id}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get all movie genres. - pub async fn get_movie_genres( - &self, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!("{}/genre/movie/list?api_key={}", self.base_url, self.api_key); - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB movie genres"); - let body = self.cached_get(&url, CacheTtl::Detail).await?; - let genres: Vec = serde_json::from_value( - body.get("genres").cloned().unwrap_or(serde_json::Value::Array(vec![])) - )?; - Ok(genres) - } - - /// Get all TV genres. - pub async fn get_tv_genres( - &self, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!("{}/genre/tv/list?api_key={}", self.base_url, self.api_key); - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB tv genres"); - let body = self.cached_get(&url, CacheTtl::Detail).await?; - let genres: Vec = serde_json::from_value( - body.get("genres").cloned().unwrap_or(serde_json::Value::Array(vec![])) - )?; - Ok(genres) - } - - /// Get available languages from TMDB. - pub async fn get_languages(&self) -> anyhow::Result> { - let url = format!("{}/configuration/languages?api_key={}", self.base_url, self.api_key); - tracing::debug!("TMDB languages"); - let value = self.cached_get(&url, CacheTtl::Detail).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get keyword details by ID. - pub async fn get_keyword(&self, keyword_id: i64) -> anyhow::Result { - let url = format!("{}/keyword/{keyword_id}?api_key={}", self.base_url, self.api_key); - tracing::debug!("TMDB keyword: {keyword_id}"); - let value = self.cached_get(&url, CacheTtl::Detail).await?; - Ok(serde_json::from_value(value)?) - } - - /// Get movies by keyword. - pub async fn get_movies_by_keyword( - &self, - keyword_id: i64, - page: Option, - language: Option<&str>, - ) -> anyhow::Result> { - let mut url = format!( - "{}/keyword/{keyword_id}/movies?api_key={}", - self.base_url, self.api_key - ); - if let Some(p) = page { url.push_str(&format!("&page={p}")); } - if let Some(lang) = language { url.push_str(&format!("&language={lang}")); } - tracing::debug!("TMDB movies by keyword: {keyword_id}"); - let value = self.cached_get(&url, CacheTtl::Search).await?; - Ok(serde_json::from_value(value)?) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path_regex}; - - #[test] - fn test_trending_item_display_title_movie() { - let item = TmdbTrendingItem { - id: 1, - media_type: "movie".into(), - title: Some("Inception".into()), - name: None, - overview: None, - release_date: None, - first_air_date: None, - poster_path: None, - backdrop_path: None, - genre_ids: vec![], - vote_average: 0.0, - vote_count: 0, - popularity: 0.0, - original_language: None, - }; - assert_eq!(item.display_title(), "Inception"); - } - - #[test] - fn test_trending_item_display_title_tv() { - let item = TmdbTrendingItem { - id: 2, - media_type: "tv".into(), - title: None, - name: Some("Breaking Bad".into()), - overview: None, - release_date: None, - first_air_date: None, - poster_path: None, - backdrop_path: None, - genre_ids: vec![], - vote_average: 0.0, - vote_count: 0, - popularity: 0.0, - original_language: None, - }; - assert_eq!(item.display_title(), "Breaking Bad"); - } - - #[test] - fn test_trending_item_display_title_unknown() { - let item = TmdbTrendingItem { - id: 3, - media_type: "tv".into(), - title: None, - name: None, - overview: None, - release_date: None, - first_air_date: None, - poster_path: None, - backdrop_path: None, - genre_ids: vec![], - vote_average: 0.0, - vote_count: 0, - popularity: 0.0, - original_language: None, - }; - assert_eq!(item.display_title(), "Unknown"); - } - - #[test] - fn test_discover_filters_to_query_pairs() { - let filters = DiscoverFilters { - page: Some(2), - sort_by: Some("popularity.desc".into()), - with_genres: Some("28,12".into()), - vote_average_gte: Some(7.0), - ..Default::default() - }; - let pairs = filters.to_query_pairs(); - assert!(pairs.contains(&("page", "2".into()))); - assert!(pairs.contains(&("sort_by", "popularity.desc".into()))); - assert!(pairs.contains(&("with_genres", "28,12".into()))); - assert!(pairs.contains(&("vote_average.gte", "7".into()))); - } - - #[test] - fn test_discover_filters_empty() { - let filters = DiscoverFilters::default(); - assert!(filters.to_query_pairs().is_empty()); - } - - #[tokio::test] - async fn test_search_series_wiremock() { - let mock_server = MockServer::start().await; - - let body = serde_json::json!({ - "page": 1, - "total_pages": 1, - "total_results": 1, - "results": [{ - "id": 1396, - "name": "Breaking Bad", - "overview": "A chemistry teacher diagnosed with cancer.", - "first_air_date": "2008-01-20", - "genre_ids": [18, 80], - "vote_average": 8.9, - "popularity": 100.0, - "vote_count": 5000 - }] - }); - - Mock::given(method("GET")) - .and(path_regex(r"/search/tv.*")) - .respond_with(ResponseTemplate::new(200).set_body_json(&body)) - .mount(&mock_server) - .await; - - let client = TmdbClient::with_base_url("test-key".into(), mock_server.uri()); - let results = client.search_series("Breaking Bad", None).await.unwrap(); - assert_eq!(results.total_results, 1); - assert_eq!(results.results[0].name, "Breaking Bad"); - assert_eq!(results.results[0].id, 1396); - } - - #[tokio::test] - async fn test_get_movie_detail_wiremock() { - let mock_server = MockServer::start().await; - - let body = serde_json::json!({ - "id": 550, - "title": "Fight Club", - "overview": "An insomniac office worker...", - "release_date": "1999-10-15", - "status": "Released", - "runtime": 139, - "budget": 63000000, - "revenue": 101209702, - "genres": [{"id": 18, "name": "Drama"}], - "imdb_id": "tt0137523", - "production_companies": [{"id": 508, "name": "Regency Enterprises"}] - }); - - Mock::given(method("GET")) - .and(path_regex(r"/movie/550.*")) - .respond_with(ResponseTemplate::new(200).set_body_json(&body)) - .mount(&mock_server) - .await; - - let client = TmdbClient::with_base_url("test-key".into(), mock_server.uri()); - let movie = client.get_movie(550).await.unwrap(); - assert_eq!(movie.id, 550); - assert_eq!(movie.title, "Fight Club"); - assert_eq!(movie.runtime, Some(139)); - assert_eq!(movie.imdb_id.as_deref(), Some("tt0137523")); - assert_eq!(movie.genres.len(), 1); - assert_eq!(movie.genres[0].name, "Drama"); - } - - #[tokio::test] - async fn test_get_trending_wiremock() { - let mock_server = MockServer::start().await; - - let body = serde_json::json!({ - "page": 1, - "total_pages": 5, - "total_results": 100, - "results": [ - {"id": 1, "media_type": "movie", "title": "Trending Movie", "vote_average": 7.5, "popularity": 50.0, "vote_count": 100, "genre_ids": []}, - {"id": 2, "media_type": "tv", "name": "Trending Show", "vote_average": 8.0, "popularity": 60.0, "vote_count": 200, "genre_ids": []} - ] - }); - - Mock::given(method("GET")) - .and(path_regex(r"/trending/.*")) - .respond_with(ResponseTemplate::new(200).set_body_json(&body)) - .mount(&mock_server) - .await; - - let client = TmdbClient::with_base_url("test-key".into(), mock_server.uri()); - let results = client.get_trending("all", "week", None, None).await.unwrap(); - assert_eq!(results.results.len(), 2); - assert_eq!(results.results[0].display_title(), "Trending Movie"); - assert_eq!(results.results[1].display_title(), "Trending Show"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/Cargo.toml deleted file mode 100644 index 01b277a3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "stackarr-migrate" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-parser = { workspace = true } -sqlx = { workspace = true } -rusqlite = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } - -[dev-dependencies] -stackarr-core = { workspace = true, features = ["testing"] } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/lib.rs deleted file mode 100644 index 58cc9287..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/lib.rs +++ /dev/null @@ -1,198 +0,0 @@ -pub mod prowlarr; -pub mod radarr; -pub mod sonarr; -pub mod writer; - -pub use writer::{MigrationData, MigrationReport, MigrationWriter}; - -use tracing::info; - -/// A path prefix mapping: replace `from` with `to` in all imported paths. -#[derive(Debug, Clone, serde::Deserialize)] -pub struct PathMapping { - pub from: String, - pub to: String, -} - -/// Apply path mappings to a string, replacing the first matching `from` prefix with `to`. -fn remap_path(path: &mut String, mappings: &[PathMapping]) { - for m in mappings { - if path.starts_with(&m.from) { - *path = format!("{}{}", m.to, &path[m.from.len()..]); - return; - } - } -} - -/// Run a complete migration from *arr SQLite databases to StackArr Postgres. -/// -/// Provide `None` for any database you don't want to import. -/// `path_mappings` remaps imported paths (root folders, series/movie directories) -/// from the old *arr container mounts to StackArr's mounts. -/// When `dry_run` is true, all data is read and merged but nothing is written to Postgres. -pub async fn run_migration( - pool: &sqlx::PgPool, - sonarr_db: Option<&std::path::Path>, - radarr_db: Option<&std::path::Path>, - prowlarr_db: Option<&std::path::Path>, - path_mappings: &[PathMapping], - dry_run: bool, -) -> anyhow::Result { - // 1. Read from each provided DB (blocking I/O, run on spawn_blocking) - let sonarr_data = match sonarr_db { - Some(path) => { - info!("reading Sonarr database: {}", path.display()); - let p = path.to_path_buf(); - let data = - tokio::task::spawn_blocking(move || sonarr::read_sonarr(&p)).await??; - Some(data) - } - None => None, - }; - - let radarr_data = match radarr_db { - Some(path) => { - info!("reading Radarr database: {}", path.display()); - let p = path.to_path_buf(); - let data = - tokio::task::spawn_blocking(move || radarr::read_radarr(&p)).await??; - Some(data) - } - None => None, - }; - - let prowlarr_data = match prowlarr_db { - Some(path) => { - info!("reading Prowlarr database: {}", path.display()); - let p = path.to_path_buf(); - let data = - tokio::task::spawn_blocking(move || prowlarr::read_prowlarr(&p)).await??; - Some(data) - } - None => None, - }; - - // 2. Merge into MigrationData (with dedup) - let (mut data, merge_warnings) = writer::build_migration_data( - sonarr_data.as_ref(), - radarr_data.as_ref(), - prowlarr_data.as_ref(), - ); - - // 3. Apply path mappings - if !path_mappings.is_empty() { - let mut remapped = 0usize; - for f in &mut data.media_library_folders { - let before = f.path.clone(); - remap_path(&mut f.path, path_mappings); - if f.path != before { - info!(from = %before, to = %f.path, "remapped library folder path"); - remapped += 1; - } - } - for s in &mut data.series { - let before = s.path.clone(); - remap_path(&mut s.path, path_mappings); - if s.path != before { - remapped += 1; - } - } - for m in &mut data.movies { - let before = m.path.clone(); - remap_path(&mut m.path, path_mappings); - if m.path != before { - remapped += 1; - } - } - info!(remapped, "applied path mappings"); - } - - info!( - "merged migration data: {} series, {} movies, {} episodes, {} media files, {} profiles, {} indexers, {} download clients", - data.series.len(), - data.movies.len(), - data.episodes.len(), - data.media_files.len(), - data.quality_profiles.len(), - data.indexers.len(), - data.download_clients.len(), - ); - - // 4. Dry-run: count everything and return report without writing - if dry_run { - info!("dry run mode -- no data will be written to Postgres"); - return Ok(MigrationReport { - series_imported: data.series.len(), - movies_imported: data.movies.len(), - episodes_imported: data.episodes.len(), - media_files_imported: data.media_files.len(), - quality_profiles_imported: data.quality_profiles.len(), - indexers_imported: data.indexers.len(), - download_clients_imported: data.download_clients.len(), - history_events_imported: data.history.len(), - blocklist_entries_imported: data.blocklist.len(), - warnings: merge_warnings, - dry_run: true, - }); - } - - // 5. Write to Postgres - let writer = MigrationWriter::new(pool.clone()); - let mut report = writer.write_all(data).await?; - report.warnings.extend(merge_warnings); - - info!("{report}"); - - Ok(report) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn remap_path_replaces_prefix() { - let mappings = vec![ - PathMapping { from: "/mnt/movies1/".into(), to: "/media/Movies1/".into() }, - PathMapping { from: "/TV1/".into(), to: "/media/TV1/".into() }, - ]; - - let mut p = "/mnt/movies1/Some Movie (2020)".to_string(); - remap_path(&mut p, &mappings); - assert_eq!(p, "/media/Movies1/Some Movie (2020)"); - - let mut p = "/TV1/Some Show (2019)".to_string(); - remap_path(&mut p, &mappings); - assert_eq!(p, "/media/TV1/Some Show (2019)"); - } - - #[test] - fn remap_path_no_match_unchanged() { - let mappings = vec![ - PathMapping { from: "/mnt/movies1/".into(), to: "/media/Movies1/".into() }, - ]; - - let mut p = "/other/path".to_string(); - remap_path(&mut p, &mappings); - assert_eq!(p, "/other/path"); - } - - #[test] - fn remap_path_first_match_wins() { - let mappings = vec![ - PathMapping { from: "/mnt/".into(), to: "/short/".into() }, - PathMapping { from: "/mnt/movies1/".into(), to: "/long/".into() }, - ]; - - let mut p = "/mnt/movies1/foo".to_string(); - remap_path(&mut p, &mappings); - assert_eq!(p, "/short/movies1/foo"); - } - - #[test] - fn remap_path_empty_mappings() { - let mut p = "/some/path".to_string(); - remap_path(&mut p, &[]); - assert_eq!(p, "/some/path"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/prowlarr.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/prowlarr.rs deleted file mode 100644 index 0d410806..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/prowlarr.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use rusqlite::{Connection, OpenFlags}; -use serde::Deserialize; -use tracing::{debug, warn}; - -// --------------------------------------------------------------------------- -// Intermediate structs – mirror the Prowlarr SQLite schema -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct ProwlarrIndexer { - pub id: i64, - pub name: String, - pub implementation: String, - pub settings: Option, - pub enable: bool, - pub priority: i32, - pub added: Option, - pub redirect: bool, - pub app_profile_id: Option, - pub tags: Option, - pub download_client_id: Option, -} - -#[derive(Debug, Clone)] -pub struct ProwlarrTag { - pub id: i64, - pub label: String, -} - -/// All data extracted from a Prowlarr SQLite database. -#[derive(Debug, Clone)] -pub struct ProwlarrData { - pub indexers: Vec, - pub tags: Vec, -} - -// --------------------------------------------------------------------------- -// Settings JSON helpers -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize, Default)] -#[serde(default)] -pub struct ProwlarrSettings { - #[serde(alias = "baseUrl", alias = "BaseUrl")] - pub base_url: Option, - #[serde(alias = "apiKey", alias = "ApiKey")] - pub api_key: Option, - #[serde(alias = "apiPath", alias = "ApiPath")] - pub api_path: Option, - #[serde(alias = "categories", alias = "Categories")] - pub categories: Option>, - #[serde(alias = "definitionFile", alias = "DefinitionFile")] - pub definition_file: Option, -} - -pub fn parse_prowlarr_settings(json: &str) -> ProwlarrSettings { - serde_json::from_str(json).unwrap_or_default() -} - -// --------------------------------------------------------------------------- -// Protocol mapping -// --------------------------------------------------------------------------- - -/// Map Prowlarr Implementation to StackArr protocol. -/// "Newznab" -> usenet, "Cardigann"/"Torznab" -> torrent -pub fn map_prowlarr_protocol(implementation: &str) -> &'static str { - match implementation { - "Newznab" => "usenet", - "Cardigann" | "Torznab" => "torrent", - _ => "torrent", - } -} - -/// Map Prowlarr Implementation to StackArr indexer_type. -/// "Cardigann" maps to "Torznab" since Cardigann is a torrent indexer framework. -pub fn map_prowlarr_indexer_type(implementation: &str) -> &'static str { - match implementation { - "Newznab" => "Newznab", - "Cardigann" => "Torznab", - "Torznab" => "Torznab", - _ => "Torznab", - } -} - -// --------------------------------------------------------------------------- -// Read the entire Prowlarr database -// --------------------------------------------------------------------------- - -pub fn read_prowlarr(path: &Path) -> Result { - let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("failed to open Prowlarr DB at {}", path.display()))?; - - debug!("reading Prowlarr database from {}", path.display()); - - let indexers = read_indexers(&conn)?; - let tags = read_tags(&conn)?; - - debug!("Prowlarr: {} indexers, {} tags", indexers.len(), tags.len()); - - Ok(ProwlarrData { indexers, tags }) -} - -fn read_indexers(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Name, Implementation, Settings, Enable, Priority, - Added, Redirect, AppProfileId, Tags, DownloadClientId - FROM Indexers", - )?; - - let rows = stmt.query_map([], |row| { - Ok(ProwlarrIndexer { - id: row.get(0)?, - name: row.get(1)?, - implementation: row.get(2)?, - settings: row.get(3)?, - enable: row.get::<_, i32>(4)? != 0, - priority: row.get::<_, i32>(5).unwrap_or(25), - added: row.get(6)?, - redirect: row.get::<_, i32>(7).unwrap_or(0) != 0, - app_profile_id: row.get(8)?, - tags: row.get(9)?, - download_client_id: row.get(10)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(i) => result.push(i), - Err(e) => warn!("skipping malformed Prowlarr indexer row: {e}"), - } - } - Ok(result) -} - -fn read_tags(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT Id, Label FROM Tags")?; - let rows = stmt.query_map([], |row| { - Ok(ProwlarrTag { - id: row.get(0)?, - label: row.get(1)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(t) => result.push(t), - Err(e) => warn!("skipping malformed Prowlarr tag row: {e}"), - } - } - Ok(result) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/radarr.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/radarr.rs deleted file mode 100644 index fe418a29..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/radarr.rs +++ /dev/null @@ -1,542 +0,0 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use rusqlite::{Connection, OpenFlags}; -use tracing::{debug, warn}; - - -// --------------------------------------------------------------------------- -// Intermediate structs – mirror the Radarr SQLite schema -// --------------------------------------------------------------------------- - -/// Movie joined with MovieMetadata. -#[derive(Debug, Clone)] -pub struct RadarrMovie { - // From Movies table - pub id: i64, - pub path: String, - pub monitored: bool, - pub quality_profile_id: i64, - pub added: Option, - pub tags: Option, - pub movie_file_id: Option, - pub minimum_availability: i32, - // From MovieMetadata table (joined) - pub tmdb_id: Option, - pub imdb_id: Option, - pub images: Option, - pub genres: Option, - pub title: String, - pub sort_title: String, - pub clean_title: String, - pub original_title: Option, - pub status: i32, - pub runtime: Option, - pub in_cinemas: Option, - pub physical_release: Option, - pub digital_release: Option, - pub year: Option, - pub ratings: Option, - pub certification: Option, - pub studio: Option, - pub overview: Option, - pub collection_tmdb_id: Option, - pub collection_title: Option, - /// Radarr language ID for the movie's original language. - pub original_language: Option, -} - -#[derive(Debug, Clone)] -pub struct RadarrMovieFile { - pub id: i64, - pub movie_id: i64, - pub quality: Option, - pub size: i64, - pub date_added: Option, - pub scene_name: Option, - pub media_info: Option, - pub release_group: Option, - pub relative_path: Option, - pub edition: Option, - pub languages: Option, - pub indexer_flags: i32, -} - -#[derive(Debug, Clone)] -pub struct RadarrQualityProfile { - pub id: i64, - pub name: String, - pub cutoff: i32, - pub items: String, - pub upgrade_allowed: bool, - pub format_items: Option, - pub min_format_score: i32, - pub cutoff_format_score: i32, - /// Radarr language ID: -1=Any, -2=Original, 1=English, 2=French, etc. - pub language: i32, -} - -#[derive(Debug, Clone)] -pub struct RadarrIndexer { - pub id: i64, - pub name: String, - pub implementation: String, - pub settings: Option, - pub enable_rss: bool, - pub enable_automatic_search: bool, - pub enable_interactive_search: bool, - pub priority: i32, - pub tags: Option, -} - -#[derive(Debug, Clone)] -pub struct RadarrDownloadClient { - pub id: i64, - pub enable: bool, - pub name: String, - pub implementation: String, - pub settings: Option, - pub priority: i32, - pub tags: Option, -} - -#[derive(Debug, Clone)] -pub struct RadarrTag { - pub id: i64, - pub label: String, -} - -#[derive(Debug, Clone)] -pub struct RadarrNamingConfig { - pub replace_illegal_characters: bool, - pub standard_movie_format: Option, - pub movie_folder_format: Option, - pub colon_replacement_format: i32, - pub rename_movies: bool, -} - -#[derive(Debug, Clone)] -pub struct RadarrHistory { - pub id: i64, - pub movie_id: i64, - pub source_title: String, - pub date: String, - pub quality: Option, - pub data: Option, - pub event_type: i32, - pub download_id: Option, - pub languages: Option, -} - -#[derive(Debug, Clone)] -pub struct RadarrBlocklist { - pub id: i64, - pub movie_id: i64, - pub source_title: String, - pub quality: Option, - pub date: Option, - pub torrent_info_hash: Option, - pub languages: Option, - pub indexer_flags: i32, -} - -/// All data extracted from a Radarr SQLite database. -#[derive(Debug, Clone)] -pub struct RadarrData { - pub movies: Vec, - pub movie_files: Vec, - pub quality_profiles: Vec, - pub indexers: Vec, - pub download_clients: Vec, - pub root_folders: Vec, - pub tags: Vec, - pub naming_config: Option, - pub history: Vec, - pub blocklist: Vec, -} - -// --------------------------------------------------------------------------- -// Enum mapping helpers -// --------------------------------------------------------------------------- - -pub fn map_minimum_availability(val: i32) -> &'static str { - match val { - 1 => "announced", - 3 => "in_cinemas", - 4 => "released", - _ => "released", - } -} - -// --------------------------------------------------------------------------- -// Read the entire Radarr database -// --------------------------------------------------------------------------- - -pub fn read_radarr(path: &Path) -> Result { - let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("failed to open Radarr DB at {}", path.display()))?; - - debug!("reading Radarr database from {}", path.display()); - - let movies = read_movies(&conn)?; - let movie_files = read_movie_files(&conn)?; - let quality_profiles = read_quality_profiles(&conn)?; - let indexers = read_indexers(&conn)?; - let download_clients = read_download_clients(&conn)?; - let root_folders = read_root_folders(&conn)?; - let tags = read_tags(&conn)?; - let naming_config = read_naming_config(&conn)?; - let history = read_history(&conn)?; - let blocklist = read_blocklist(&conn)?; - - debug!( - "Radarr: {} movies, {} files, {} profiles", - movies.len(), - movie_files.len(), - quality_profiles.len() - ); - - Ok(RadarrData { - movies, - movie_files, - quality_profiles, - indexers, - download_clients, - root_folders, - tags, - naming_config, - history, - blocklist, - }) -} - -fn read_movies(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT m.Id, m.Path, m.Monitored, m.QualityProfileId, m.Added, m.Tags, - m.MovieFileId, m.MinimumAvailability, - mm.TmdbId, mm.ImdbId, mm.Images, mm.Genres, mm.Title, mm.SortTitle, - mm.CleanTitle, mm.OriginalTitle, mm.Status, mm.Runtime, - mm.InCinemas, mm.PhysicalRelease, mm.DigitalRelease, mm.Year, - mm.Ratings, mm.Certification, mm.Studio, mm.Overview, - mm.CollectionTmdbId, mm.CollectionTitle, mm.OriginalLanguage - FROM Movies m - JOIN MovieMetadata mm ON m.MovieMetadataId = mm.Id", - )?; - - let rows = stmt.query_map([], |row| { - let file_id: Option = row.get(6)?; - Ok(RadarrMovie { - id: row.get(0)?, - path: row.get(1)?, - monitored: row.get::<_, i32>(2)? != 0, - quality_profile_id: row.get(3)?, - added: row.get(4)?, - tags: row.get(5)?, - movie_file_id: file_id.filter(|&id| id > 0), - minimum_availability: row.get(7)?, - tmdb_id: row.get(8)?, - imdb_id: row.get(9)?, - images: row.get(10)?, - genres: row.get(11)?, - title: row.get(12)?, - sort_title: row.get(13)?, - clean_title: row.get(14)?, - original_title: row.get(15)?, - status: row.get(16)?, - runtime: row.get(17)?, - in_cinemas: row.get(18)?, - physical_release: row.get(19)?, - digital_release: row.get(20)?, - year: row.get(21)?, - ratings: row.get(22)?, - certification: row.get(23)?, - studio: row.get(24)?, - overview: row.get(25)?, - collection_tmdb_id: row.get(26)?, - collection_title: row.get(27)?, - original_language: row.get::<_, Option>(28).unwrap_or(None), - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(m) => result.push(m), - Err(e) => warn!("skipping malformed Radarr movie row: {e}"), - } - } - Ok(result) -} - -fn read_movie_files(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, MovieId, Quality, Size, DateAdded, SceneName, - MediaInfo, ReleaseGroup, RelativePath, Edition, Languages, IndexerFlags - FROM MovieFiles", - )?; - - let rows = stmt.query_map([], |row| { - Ok(RadarrMovieFile { - id: row.get(0)?, - movie_id: row.get(1)?, - quality: row.get(2)?, - size: row.get(3)?, - date_added: row.get(4)?, - scene_name: row.get(5)?, - media_info: row.get(6)?, - release_group: row.get(7)?, - relative_path: row.get(8)?, - edition: row.get(9)?, - languages: row.get(10)?, - indexer_flags: row.get::<_, i32>(11).unwrap_or(0), - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(f) => result.push(f), - Err(e) => warn!("skipping malformed Radarr movie file row: {e}"), - } - } - Ok(result) -} - -fn read_quality_profiles(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Name, Cutoff, Items, UpgradeAllowed, - FormatItems, MinFormatScore, CutoffFormatScore, Language - FROM QualityProfiles", - )?; - - let rows = stmt.query_map([], |row| { - Ok(RadarrQualityProfile { - id: row.get(0)?, - name: row.get(1)?, - cutoff: row.get(2)?, - items: row.get(3)?, - upgrade_allowed: row.get::<_, i32>(4)? != 0, - format_items: row.get(5)?, - min_format_score: row.get::<_, i32>(6).unwrap_or(0), - cutoff_format_score: row.get::<_, i32>(7).unwrap_or(0), - language: row.get::<_, i32>(8).unwrap_or(-1), - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(p) => result.push(p), - Err(e) => warn!("skipping malformed Radarr quality profile row: {e}"), - } - } - Ok(result) -} - -fn read_indexers(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Name, Implementation, Settings, EnableRss, - EnableAutomaticSearch, EnableInteractiveSearch, Priority, Tags - FROM Indexers", - )?; - - let rows = stmt.query_map([], |row| { - Ok(RadarrIndexer { - id: row.get(0)?, - name: row.get(1)?, - implementation: row.get(2)?, - settings: row.get(3)?, - enable_rss: row.get::<_, i32>(4)? != 0, - enable_automatic_search: row.get::<_, i32>(5)? != 0, - enable_interactive_search: row.get::<_, i32>(6)? != 0, - priority: row.get::<_, i32>(7).unwrap_or(25), - tags: row.get(8)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(i) => result.push(i), - Err(e) => warn!("skipping malformed Radarr indexer row: {e}"), - } - } - Ok(result) -} - -fn read_download_clients(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Enable, Name, Implementation, Settings, Priority, Tags - FROM DownloadClients", - )?; - - let rows = stmt.query_map([], |row| { - Ok(RadarrDownloadClient { - id: row.get(0)?, - enable: row.get::<_, i32>(1)? != 0, - name: row.get(2)?, - implementation: row.get(3)?, - settings: row.get(4)?, - priority: row.get::<_, i32>(5).unwrap_or(1), - tags: row.get(6)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(dc) => result.push(dc), - Err(e) => warn!("skipping malformed Radarr download client row: {e}"), - } - } - Ok(result) -} - -fn read_root_folders(conn: &Connection) -> Result> { - // Radarr may or may not have a RootFolders table; extract from movie paths if missing - let has_root_folders = conn - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='RootFolders'") - .and_then(|mut s| s.query_row([], |_| Ok(()))) - .is_ok(); - - if has_root_folders { - let mut stmt = conn.prepare("SELECT Path FROM RootFolders")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - let mut result = Vec::new(); - for row in rows { - match row { - Ok(p) => result.push(p), - Err(e) => warn!("skipping malformed Radarr root folder row: {e}"), - } - } - Ok(result) - } else { - Ok(Vec::new()) - } -} - -fn read_tags(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT Id, Label FROM Tags")?; - let rows = stmt.query_map([], |row| { - Ok(RadarrTag { - id: row.get(0)?, - label: row.get(1)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(t) => result.push(t), - Err(e) => warn!("skipping malformed Radarr tag row: {e}"), - } - } - Ok(result) -} - -fn read_naming_config(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT ReplaceIllegalCharacters, StandardMovieFormat, MovieFolderFormat, - ColonReplacementFormat, RenameMovies - FROM NamingConfig LIMIT 1", - )?; - - let mut rows = stmt.query_map([], |row| { - Ok(RadarrNamingConfig { - replace_illegal_characters: row.get::<_, i32>(0)? != 0, - standard_movie_format: row.get(1)?, - movie_folder_format: row.get(2)?, - colon_replacement_format: row.get::<_, i32>(3).unwrap_or(4), - rename_movies: row.get::<_, i32>(4)? != 0, - }) - })?; - - match rows.next() { - Some(Ok(nc)) => Ok(Some(nc)), - Some(Err(e)) => { - warn!("failed to read Radarr naming config: {e}"); - Ok(None) - } - None => Ok(None), - } -} - -fn read_history(conn: &Connection) -> Result> { - // Check if History table exists -- some Radarr installs may not have it - let has_history = conn - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='History'") - .and_then(|mut s| s.query_row([], |_| Ok(()))) - .is_ok(); - - if !has_history { - return Ok(Vec::new()); - } - - let mut stmt = conn.prepare( - "SELECT Id, MovieId, SourceTitle, Date, Quality, - Data, EventType, DownloadId, Languages - FROM History", - )?; - - let rows = stmt.query_map([], |row| { - Ok(RadarrHistory { - id: row.get(0)?, - movie_id: row.get(1)?, - source_title: row.get(2)?, - date: row.get(3)?, - quality: row.get(4)?, - data: row.get(5)?, - event_type: row.get(6)?, - download_id: row.get(7)?, - languages: row.get(8)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(h) => result.push(h), - Err(e) => warn!("skipping malformed Radarr history row: {e}"), - } - } - Ok(result) -} - -fn read_blocklist(conn: &Connection) -> Result> { - let has_blocklist = conn - .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='Blocklist'") - .and_then(|mut s| s.query_row([], |_| Ok(()))) - .is_ok(); - - if !has_blocklist { - return Ok(Vec::new()); - } - - let mut stmt = conn.prepare( - "SELECT Id, MovieId, SourceTitle, Quality, - Date, TorrentInfoHash, Languages, IndexerFlags - FROM Blocklist", - )?; - - let rows = stmt.query_map([], |row| { - Ok(RadarrBlocklist { - id: row.get(0)?, - movie_id: row.get(1)?, - source_title: row.get(2)?, - quality: row.get(3)?, - date: row.get(4)?, - torrent_info_hash: row.get(5)?, - languages: row.get(6)?, - indexer_flags: row.get::<_, i32>(7).unwrap_or(0), - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(b) => result.push(b), - Err(e) => warn!("skipping malformed Radarr blocklist row: {e}"), - } - } - Ok(result) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/sonarr.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/sonarr.rs deleted file mode 100644 index 1331dda3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/sonarr.rs +++ /dev/null @@ -1,853 +0,0 @@ -use std::path::Path; - -use anyhow::{Context, Result}; -use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; -use rusqlite::{Connection, OpenFlags}; -use serde::Deserialize; -use tracing::{debug, warn}; - -// --------------------------------------------------------------------------- -// Intermediate structs – mirror the Sonarr SQLite schema -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct SonarrSeries { - pub id: i64, - pub tvdb_id: Option, - pub imdb_id: Option, - pub title: String, - pub title_slug: Option, - pub clean_title: String, - pub status: i32, - pub overview: Option, - pub air_time: Option, - pub images: Option, - pub path: String, - pub monitored: bool, - pub season_folder: bool, - pub runtime: Option, - pub series_type: i32, - pub network: Option, - pub use_scene_numbering: bool, - pub first_aired: Option, - pub year: Option, - pub seasons: Option, - pub sort_title: String, - pub quality_profile_id: i64, - pub tags: Option, - pub added: Option, - pub tvmaze_id: Option, - pub tmdb_id: Option, -} - -#[derive(Debug, Clone)] -pub struct SonarrEpisode { - pub id: i64, - pub series_id: i64, - pub season_number: i32, - pub episode_number: i32, - pub title: Option, - pub overview: Option, - pub episode_file_id: Option, - pub absolute_episode_number: Option, - pub scene_absolute_episode_number: Option, - pub scene_season_number: Option, - pub scene_episode_number: Option, - pub monitored: bool, - pub air_date_utc: Option, - pub air_date: Option, - pub last_search_time: Option, - pub tvdb_id: Option, - pub runtime: Option, - pub finale_type: Option, -} - -#[derive(Debug, Clone)] -pub struct SonarrEpisodeFile { - pub id: i64, - pub series_id: i64, - pub quality: Option, - pub size: i64, - pub date_added: Option, - pub season_number: i32, - pub scene_name: Option, - pub release_group: Option, - pub media_info: Option, - pub relative_path: Option, - pub languages: Option, - pub indexer_flags: i32, - pub release_hash: Option, -} - -#[derive(Debug, Clone)] -pub struct SonarrQualityProfile { - pub id: i64, - pub name: String, - pub cutoff: i32, - pub items: String, - pub upgrade_allowed: bool, - pub format_items: Option, - pub min_format_score: i32, - pub cutoff_format_score: i32, -} - -#[derive(Debug, Clone)] -pub struct SonarrIndexer { - pub id: i64, - pub name: String, - pub implementation: String, - pub settings: Option, - pub enable_rss: bool, - pub enable_automatic_search: bool, - pub enable_interactive_search: bool, - pub priority: i32, - pub tags: Option, -} - -#[derive(Debug, Clone)] -pub struct SonarrDownloadClient { - pub id: i64, - pub enable: bool, - pub name: String, - pub implementation: String, - pub settings: Option, - pub priority: i32, - pub tags: Option, -} - -#[derive(Debug, Clone)] -pub struct SonarrTag { - pub id: i64, - pub label: String, -} - -#[derive(Debug, Clone)] -pub struct SonarrNamingConfig { - pub multi_episode_style: i32, - pub rename_episodes: bool, - pub standard_episode_format: Option, - pub daily_episode_format: Option, - pub season_folder_format: Option, - pub series_folder_format: Option, - pub anime_episode_format: Option, - pub colon_replacement_format: i32, -} - -#[derive(Debug, Clone)] -pub struct SonarrHistory { - pub id: i64, - pub episode_id: i64, - pub series_id: i64, - pub source_title: String, - pub date: String, - pub quality: Option, - pub data: Option, - pub event_type: i32, - pub download_id: Option, - pub languages: Option, -} - -#[derive(Debug, Clone)] -pub struct SonarrBlocklist { - pub id: i64, - pub series_id: i64, - pub episode_ids: Option, - pub source_title: String, - pub quality: Option, - pub date: Option, - pub torrent_info_hash: Option, - pub languages: Option, - pub indexer_flags: i32, -} - -#[derive(Debug, Clone)] -pub struct SonarrSeason { - pub season_number: i32, - pub monitored: bool, -} - -/// All data extracted from a Sonarr SQLite database. -#[derive(Debug, Clone)] -pub struct SonarrData { - pub series: Vec, - pub episodes: Vec, - pub episode_files: Vec, - pub quality_profiles: Vec, - pub indexers: Vec, - pub download_clients: Vec, - pub root_folders: Vec, - pub tags: Vec, - pub naming_config: Option, - pub history: Vec, - pub blocklist: Vec, -} - -// --------------------------------------------------------------------------- -// Settings JSON helpers -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize, Default)] -#[serde(default)] -pub struct IndexerSettings { - #[serde(alias = "baseUrl", alias = "BaseUrl")] - pub base_url: Option, - #[serde(alias = "apiKey", alias = "ApiKey")] - pub api_key: Option, - #[serde(alias = "apiPath", alias = "ApiPath")] - pub api_path: Option, - #[serde(alias = "categories", alias = "Categories")] - pub categories: Option>, -} - -#[derive(Debug, Deserialize, Default)] -#[serde(default)] -pub struct DownloadClientSettings { - #[serde(alias = "host", alias = "Host")] - pub host: Option, - #[serde(alias = "port", alias = "Port")] - pub port: Option, - #[serde(alias = "urlBase", alias = "UrlBase")] - pub url_base: Option, - #[serde(alias = "username", alias = "Username")] - pub username: Option, - #[serde(alias = "password", alias = "Password")] - pub password: Option, - #[serde(alias = "apiKey", alias = "ApiKey")] - pub api_key: Option, - #[serde(alias = "useSsl", alias = "UseSsl")] - pub use_ssl: Option, - #[serde(alias = "category", alias = "Category")] - pub category: Option, -} - -pub fn parse_indexer_settings(json: &str) -> IndexerSettings { - serde_json::from_str(json).unwrap_or_default() -} - -pub fn parse_download_client_settings(json: &str) -> DownloadClientSettings { - serde_json::from_str(json).unwrap_or_default() -} - -// --------------------------------------------------------------------------- -// Enum mapping helpers -// --------------------------------------------------------------------------- - -pub fn map_series_status(status: i32) -> &'static str { - match status { - 0 => "continuing", - 1 => "ended", - 2 => "upcoming", - 3 => "deleted", - _ => "continuing", - } -} - -pub fn map_series_type(st: i32) -> &'static str { - match st { - 0 => "standard", - 1 => "daily", - 2 => "anime", - _ => "standard", - } -} - -pub fn map_event_type(et: i32) -> &'static str { - match et { - 1 => "grabbed", - 3 => "imported", - 4 => "download_failed", - 5 => "file_deleted", - 6 => "file_renamed", - 8 => "download_ignored", - _ => "grabbed", - } -} - -pub fn map_implementation_to_protocol(imp: &str) -> &'static str { - match imp { - "Newznab" => "usenet", - "Torznab" => "torrent", - _ => "torrent", - } -} - -pub fn map_dl_implementation_to_protocol(imp: &str) -> &'static str { - match imp { - "Sabnzbd" | "NzbGet" | "Nzbget" => "usenet", - "QBittorrent" | "Qbittorrent" | "Transmission" | "Deluge" | "RTorrent" => "torrent", - _ => "torrent", - } -} - -// --------------------------------------------------------------------------- -// Parse helpers for dates -// --------------------------------------------------------------------------- - -pub fn parse_datetime(s: &str) -> Option> { - // Sonarr stores dates in several formats, try them in order - if let Ok(dt) = DateTime::parse_from_rfc3339(s) { - return Some(dt.with_timezone(&Utc)); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") { - return Some(dt.and_utc()); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return Some(dt.and_utc()); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { - return Some(dt.and_utc()); - } - if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { - return Some(dt.and_utc()); - } - None -} - -pub fn parse_date(s: &str) -> Option { - NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() -} - -pub fn parse_time(s: &str) -> Option { - NaiveTime::parse_from_str(s, "%H:%M").ok() - .or_else(|| NaiveTime::parse_from_str(s, "%H:%M:%S").ok()) -} - -pub fn parse_seasons_json(json: &str) -> Vec { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct SeasonEntry { - season_number: i32, - monitored: bool, - } - - serde_json::from_str::>(json) - .unwrap_or_default() - .into_iter() - .map(|s| SonarrSeason { - season_number: s.season_number, - monitored: s.monitored, - }) - .collect() -} - -// --------------------------------------------------------------------------- -// Read the entire Sonarr database -// --------------------------------------------------------------------------- - -pub fn read_sonarr(path: &Path) -> Result { - let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) - .with_context(|| format!("failed to open Sonarr DB at {}", path.display()))?; - - debug!("reading Sonarr database from {}", path.display()); - - let series = read_series(&conn)?; - let episodes = read_episodes(&conn)?; - let episode_files = read_episode_files(&conn)?; - let quality_profiles = read_quality_profiles(&conn)?; - let indexers = read_indexers(&conn)?; - let download_clients = read_download_clients(&conn)?; - let root_folders = read_root_folders(&conn)?; - let tags = read_tags(&conn)?; - let naming_config = read_naming_config(&conn)?; - let history = read_history(&conn)?; - let blocklist = read_blocklist(&conn)?; - - debug!( - "Sonarr: {} series, {} episodes, {} files, {} profiles", - series.len(), - episodes.len(), - episode_files.len(), - quality_profiles.len() - ); - - Ok(SonarrData { - series, - episodes, - episode_files, - quality_profiles, - indexers, - download_clients, - root_folders, - tags, - naming_config, - history, - blocklist, - }) -} - -fn read_series(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, TvdbId, ImdbId, Title, TitleSlug, CleanTitle, Status, Overview, - AirTime, Images, Path, Monitored, SeasonFolder, Runtime, SeriesType, - Network, UseSceneNumbering, FirstAired, Year, Seasons, SortTitle, - QualityProfileId, Tags, Added, TvMazeId, TmdbId - FROM Series", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrSeries { - id: row.get(0)?, - tvdb_id: row.get(1)?, - imdb_id: row.get(2)?, - title: row.get(3)?, - title_slug: row.get(4)?, - clean_title: row.get(5)?, - status: row.get(6)?, - overview: row.get(7)?, - air_time: row.get(8)?, - images: row.get(9)?, - path: row.get(10)?, - monitored: row.get::<_, i32>(11)? != 0, - season_folder: row.get::<_, i32>(12)? != 0, - runtime: row.get(13)?, - series_type: row.get(14)?, - network: row.get(15)?, - use_scene_numbering: row.get::<_, i32>(16)? != 0, - first_aired: row.get(17)?, - year: row.get(18)?, - seasons: row.get(19)?, - sort_title: row.get(20)?, - quality_profile_id: row.get(21)?, - tags: row.get(22)?, - added: row.get(23)?, - tvmaze_id: row.get(24)?, - tmdb_id: row.get(25)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(s) => result.push(s), - Err(e) => warn!("skipping malformed Sonarr series row: {e}"), - } - } - Ok(result) -} - -fn read_episodes(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, SeriesId, SeasonNumber, EpisodeNumber, Title, Overview, - EpisodeFileId, AbsoluteEpisodeNumber, SceneAbsoluteEpisodeNumber, - SceneSeasonNumber, SceneEpisodeNumber, Monitored, AirDateUtc, - AirDate, LastSearchTime, TvdbId, Runtime, FinaleType - FROM Episodes", - )?; - - let rows = stmt.query_map([], |row| { - let file_id: Option = row.get(6)?; - Ok(SonarrEpisode { - id: row.get(0)?, - series_id: row.get(1)?, - season_number: row.get(2)?, - episode_number: row.get(3)?, - title: row.get(4)?, - overview: row.get(5)?, - episode_file_id: file_id.filter(|&id| id > 0), - absolute_episode_number: row.get(7)?, - scene_absolute_episode_number: row.get(8)?, - scene_season_number: row.get(9)?, - scene_episode_number: row.get(10)?, - monitored: row.get::<_, i32>(11)? != 0, - air_date_utc: row.get(12)?, - air_date: row.get(13)?, - last_search_time: row.get(14)?, - tvdb_id: row.get(15)?, - runtime: row.get(16)?, - finale_type: row.get(17)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(e) => result.push(e), - Err(e) => warn!("skipping malformed Sonarr episode row: {e}"), - } - } - Ok(result) -} - -fn read_episode_files(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, SeriesId, Quality, Size, DateAdded, SeasonNumber, SceneName, - ReleaseGroup, MediaInfo, RelativePath, Languages, IndexerFlags, ReleaseHash - FROM EpisodeFiles", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrEpisodeFile { - id: row.get(0)?, - series_id: row.get(1)?, - quality: row.get(2)?, - size: row.get(3)?, - date_added: row.get(4)?, - season_number: row.get(5)?, - scene_name: row.get(6)?, - release_group: row.get(7)?, - media_info: row.get(8)?, - relative_path: row.get(9)?, - languages: row.get(10)?, - indexer_flags: row.get::<_, i32>(11).unwrap_or(0), - release_hash: row.get(12)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(f) => result.push(f), - Err(e) => warn!("skipping malformed Sonarr episode file row: {e}"), - } - } - Ok(result) -} - -fn read_quality_profiles(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Name, Cutoff, Items, UpgradeAllowed, - FormatItems, MinFormatScore, CutoffFormatScore - FROM QualityProfiles", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrQualityProfile { - id: row.get(0)?, - name: row.get(1)?, - cutoff: row.get(2)?, - items: row.get(3)?, - upgrade_allowed: row.get::<_, i32>(4)? != 0, - format_items: row.get(5)?, - min_format_score: row.get::<_, i32>(6).unwrap_or(0), - cutoff_format_score: row.get::<_, i32>(7).unwrap_or(0), - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(p) => result.push(p), - Err(e) => warn!("skipping malformed Sonarr quality profile row: {e}"), - } - } - Ok(result) -} - -fn read_indexers(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Name, Implementation, Settings, EnableRss, - EnableAutomaticSearch, EnableInteractiveSearch, Priority, Tags - FROM Indexers", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrIndexer { - id: row.get(0)?, - name: row.get(1)?, - implementation: row.get(2)?, - settings: row.get(3)?, - enable_rss: row.get::<_, i32>(4)? != 0, - enable_automatic_search: row.get::<_, i32>(5)? != 0, - enable_interactive_search: row.get::<_, i32>(6)? != 0, - priority: row.get::<_, i32>(7).unwrap_or(25), - tags: row.get(8)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(i) => result.push(i), - Err(e) => warn!("skipping malformed Sonarr indexer row: {e}"), - } - } - Ok(result) -} - -fn read_download_clients(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, Enable, Name, Implementation, Settings, Priority, Tags - FROM DownloadClients", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrDownloadClient { - id: row.get(0)?, - enable: row.get::<_, i32>(1)? != 0, - name: row.get(2)?, - implementation: row.get(3)?, - settings: row.get(4)?, - priority: row.get::<_, i32>(5).unwrap_or(1), - tags: row.get(6)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(dc) => result.push(dc), - Err(e) => warn!("skipping malformed Sonarr download client row: {e}"), - } - } - Ok(result) -} - -fn read_root_folders(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT Path FROM RootFolders")?; - let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(p) => result.push(p), - Err(e) => warn!("skipping malformed Sonarr root folder row: {e}"), - } - } - Ok(result) -} - -fn read_tags(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT Id, Label FROM Tags")?; - let rows = stmt.query_map([], |row| { - Ok(SonarrTag { - id: row.get(0)?, - label: row.get(1)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(t) => result.push(t), - Err(e) => warn!("skipping malformed Sonarr tag row: {e}"), - } - } - Ok(result) -} - -fn read_naming_config(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT MultiEpisodeStyle, RenameEpisodes, StandardEpisodeFormat, - DailyEpisodeFormat, SeasonFolderFormat, SeriesFolderFormat, - AnimeEpisodeFormat, ColonReplacementFormat - FROM NamingConfig LIMIT 1", - )?; - - let mut rows = stmt.query_map([], |row| { - Ok(SonarrNamingConfig { - multi_episode_style: row.get(0)?, - rename_episodes: row.get::<_, i32>(1)? != 0, - standard_episode_format: row.get(2)?, - daily_episode_format: row.get(3)?, - season_folder_format: row.get(4)?, - series_folder_format: row.get(5)?, - anime_episode_format: row.get(6)?, - colon_replacement_format: row.get::<_, i32>(7).unwrap_or(4), - }) - })?; - - match rows.next() { - Some(Ok(nc)) => Ok(Some(nc)), - Some(Err(e)) => { - warn!("failed to read Sonarr naming config: {e}"); - Ok(None) - } - None => Ok(None), - } -} - -fn read_history(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, EpisodeId, SeriesId, SourceTitle, Date, Quality, - Data, EventType, DownloadId, Languages - FROM History", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrHistory { - id: row.get(0)?, - episode_id: row.get(1)?, - series_id: row.get(2)?, - source_title: row.get(3)?, - date: row.get(4)?, - quality: row.get(5)?, - data: row.get(6)?, - event_type: row.get(7)?, - download_id: row.get(8)?, - languages: row.get(9)?, - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(h) => result.push(h), - Err(e) => warn!("skipping malformed Sonarr history row: {e}"), - } - } - Ok(result) -} - -fn read_blocklist(conn: &Connection) -> Result> { - let mut stmt = conn.prepare( - "SELECT Id, SeriesId, EpisodeIds, SourceTitle, Quality, - Date, TorrentInfoHash, Languages, IndexerFlags - FROM Blocklist", - )?; - - let rows = stmt.query_map([], |row| { - Ok(SonarrBlocklist { - id: row.get(0)?, - series_id: row.get(1)?, - episode_ids: row.get(2)?, - source_title: row.get(3)?, - quality: row.get(4)?, - date: row.get(5)?, - torrent_info_hash: row.get(6)?, - languages: row.get(7)?, - indexer_flags: row.get::<_, i32>(8).unwrap_or(0), - }) - })?; - - let mut result = Vec::new(); - for row in rows { - match row { - Ok(b) => result.push(b), - Err(e) => warn!("skipping malformed Sonarr blocklist row: {e}"), - } - } - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{Datelike, Timelike}; - - #[test] - fn test_map_series_status() { - assert_eq!(map_series_status(0), "continuing"); - assert_eq!(map_series_status(1), "ended"); - assert_eq!(map_series_status(2), "upcoming"); - assert_eq!(map_series_status(3), "deleted"); - assert_eq!(map_series_status(99), "continuing"); // unknown defaults - } - - #[test] - fn test_map_series_type() { - assert_eq!(map_series_type(0), "standard"); - assert_eq!(map_series_type(1), "daily"); - assert_eq!(map_series_type(2), "anime"); - assert_eq!(map_series_type(99), "standard"); - } - - #[test] - fn test_map_event_type() { - assert_eq!(map_event_type(1), "grabbed"); - assert_eq!(map_event_type(3), "imported"); - assert_eq!(map_event_type(4), "download_failed"); - assert_eq!(map_event_type(5), "file_deleted"); - assert_eq!(map_event_type(6), "file_renamed"); - assert_eq!(map_event_type(8), "download_ignored"); - assert_eq!(map_event_type(99), "grabbed"); - } - - #[test] - fn test_map_implementation_to_protocol() { - assert_eq!(map_implementation_to_protocol("Newznab"), "usenet"); - assert_eq!(map_implementation_to_protocol("Torznab"), "torrent"); - assert_eq!(map_implementation_to_protocol("Unknown"), "torrent"); - } - - #[test] - fn test_map_dl_implementation_to_protocol() { - assert_eq!(map_dl_implementation_to_protocol("Sabnzbd"), "usenet"); - assert_eq!(map_dl_implementation_to_protocol("NzbGet"), "usenet"); - assert_eq!(map_dl_implementation_to_protocol("QBittorrent"), "torrent"); - assert_eq!(map_dl_implementation_to_protocol("Transmission"), "torrent"); - assert_eq!(map_dl_implementation_to_protocol("Deluge"), "torrent"); - assert_eq!(map_dl_implementation_to_protocol("Unknown"), "torrent"); - } - - #[test] - fn test_parse_datetime_rfc3339() { - let dt = parse_datetime("2024-06-15T10:30:00Z"); - assert!(dt.is_some()); - let dt = dt.unwrap(); - assert_eq!(dt.year(), 2024); - assert_eq!(dt.month(), 6); - } - - #[test] - fn test_parse_datetime_naive_format() { - let dt = parse_datetime("2024-01-15 20:30:00"); - assert!(dt.is_some()); - assert_eq!(dt.unwrap().year(), 2024); - } - - #[test] - fn test_parse_datetime_invalid() { - assert!(parse_datetime("not-a-date").is_none()); - } - - #[test] - fn test_parse_date() { - let d = parse_date("2024-03-15"); - assert!(d.is_some()); - assert_eq!(d.unwrap().day(), 15); - } - - #[test] - fn test_parse_date_invalid() { - assert!(parse_date("invalid").is_none()); - } - - #[test] - fn test_parse_time() { - let t = parse_time("20:00"); - assert!(t.is_some()); - assert_eq!(t.unwrap().hour(), 20); - } - - #[test] - fn test_parse_time_with_seconds() { - let t = parse_time("14:30:00"); - assert!(t.is_some()); - assert_eq!(t.unwrap().minute(), 30); - } - - #[test] - fn test_parse_seasons_json() { - let json = r#"[{"seasonNumber":1,"monitored":true},{"seasonNumber":2,"monitored":false}]"#; - let seasons = parse_seasons_json(json); - assert_eq!(seasons.len(), 2); - assert_eq!(seasons[0].season_number, 1); - assert!(seasons[0].monitored); - assert_eq!(seasons[1].season_number, 2); - assert!(!seasons[1].monitored); - } - - #[test] - fn test_parse_seasons_json_invalid() { - let seasons = parse_seasons_json("not json"); - assert!(seasons.is_empty()); - } - - #[test] - fn test_indexer_settings_parsing() { - let json = r#"{"baseUrl":"http://nzbgeek.info","apiKey":"abc123","categories":[5030,5040]}"#; - let settings: IndexerSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.base_url.as_deref(), Some("http://nzbgeek.info")); - assert_eq!(settings.api_key.as_deref(), Some("abc123")); - assert_eq!(settings.categories, Some(vec![5030, 5040])); - } - - #[test] - fn test_download_client_settings_parsing() { - let json = r#"{"host":"localhost","port":8080,"username":"admin","password":"secret"}"#; - let settings: DownloadClientSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.host.as_deref(), Some("localhost")); - assert_eq!(settings.port, Some(8080)); - } -} - diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/writer.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/writer.rs deleted file mode 100644 index f4a2dce3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/src/writer.rs +++ /dev/null @@ -1,1797 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{Context, Result}; -use chrono::{DateTime, Utc}; -use serde_json::Value as JsonValue; -use sqlx::PgPool; -use tracing::{debug, info, warn}; - -use crate::prowlarr::{ - ProwlarrData, map_prowlarr_indexer_type, map_prowlarr_protocol, - parse_prowlarr_settings, -}; -use crate::radarr::{RadarrData, map_minimum_availability}; - -/// Radarr uses different quality IDs from Sonarr/StackArr. -/// This maps Radarr quality IDs to StackArr IDs (which match Sonarr). -fn radarr_quality_id_to_stackarr(radarr_id: i64) -> i64 { - match radarr_id { - 0 => 0, // Unknown - 1 => 1, // SDTV - 2 => 2, // DVD - 3 => 11, // WEBDL-1080p (Radarr=3, Sonarr=11) - 4 => 6, // HDTV-720p (Radarr=4, Sonarr=6) - 5 => 7, // WEBDL-720p (Radarr=5, Sonarr=7) - 6 => 9, // Bluray-720p (Radarr=6, Sonarr=9) - 7 => 13, // Bluray-1080p (Radarr=7, Sonarr=13) - 8 => 3, // WEBDL-480p (Radarr=8, Sonarr=3) - 9 => 10, // HDTV-1080p (Radarr=9, Sonarr=10) - 10 => 20, // Raw-HD (Radarr=10, Sonarr=20) - 12 => 4, // WEBRip-480p (Radarr=12, Sonarr=4) - 14 => 8, // WEBRip-720p (Radarr=14, Sonarr=8) - 15 => 12, // WEBRip-1080p (Radarr=15, Sonarr=12) - 16 => 15, // HDTV-2160p (Radarr=16, Sonarr=15) - 17 => 17, // WEBRip-2160p (same in both) - 18 => 16, // WEBDL-2160p (Radarr=18, Sonarr=16) - 19 => 18, // Bluray-2160p (Radarr=19, Sonarr=18) - 20 => 5, // Bluray-480p (Radarr=20, Sonarr=5) - 30 => 14, // Remux-1080p (Radarr=30, Sonarr=14) - 31 => 19, // Remux-2160p (Radarr=31, Sonarr=19) - other => other, // Unknown/Radarr-only qualities pass through - } -} - -/// Recursively normalize Radarr quality IDs in profile items JSON to StackArr IDs. -fn normalize_radarr_quality_ids(items: &JsonValue) -> JsonValue { - match items { - JsonValue::Array(arr) => { - JsonValue::Array(arr.iter().map(normalize_radarr_item).collect()) - } - other => other.clone(), - } -} - -fn normalize_radarr_item(item: &JsonValue) -> JsonValue { - let Some(obj) = item.as_object() else { - return item.clone(); - }; - let mut out = obj.clone(); - - // Remap the quality ID - if let Some(q) = obj.get("quality") { - match q { - JsonValue::Number(n) => { - if let Some(id) = n.as_i64() { - let mapped = radarr_quality_id_to_stackarr(id); - out.insert("quality".to_string(), serde_json::json!(mapped)); - } - } - JsonValue::Object(qobj) => { - if let Some(id) = qobj.get("id").and_then(|v| v.as_i64()) { - let mapped = radarr_quality_id_to_stackarr(id); - let mut qobj = qobj.clone(); - qobj.insert("id".to_string(), serde_json::json!(mapped)); - out.insert("quality".to_string(), JsonValue::Object(qobj)); - } - } - _ => {} - } - } - - // Recurse into nested items (quality groups) - if let Some(nested) = obj.get("items") { - out.insert("items".to_string(), normalize_radarr_quality_ids(nested)); - } - - JsonValue::Object(out) -} -use crate::sonarr::{ - SonarrData, map_dl_implementation_to_protocol, map_event_type, - map_implementation_to_protocol, map_series_status, map_series_type, parse_datetime, - parse_date, parse_download_client_settings, parse_indexer_settings, parse_seasons_json, - parse_time, -}; - -// --------------------------------------------------------------------------- -// Insert structs – what we write to Postgres -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct QualityProfileInsert { - pub name: String, - pub cutoff: i32, - pub upgrade_allowed: bool, - pub min_format_score: i32, - pub cutoff_format_score: i32, - pub items: JsonValue, - /// The source *arr old ID, used for mapping. - pub old_id: i64, - /// Media type: "series", "movie", or None for any. - pub media_type: Option, - /// Radarr language preference: -1=Any, -2=Original, positive=specific. - pub language: i32, -} - -#[derive(Debug, Clone)] -pub struct MediaLibraryFolderInsert { - pub path: String, - pub media_type: String, -} - -#[derive(Debug, Clone)] -pub struct NamingConfigInsert { - pub media_type: String, - pub rename_files: bool, - pub standard_format: Option, - pub daily_format: Option, - pub anime_format: Option, - pub season_folder_format: Option, - pub movie_format: Option, - pub movie_folder_format: Option, - pub colon_replacement: String, -} - -#[derive(Debug, Clone)] -pub struct IndexerInsert { - pub name: String, - pub indexer_type: String, - pub base_url: String, - pub api_key: Option, - pub protocol: String, - pub categories: Option>, - pub enabled: bool, - pub priority: i32, - pub supports_search: bool, - pub supports_rss: bool, - pub config: Option, -} - -#[derive(Debug, Clone)] -pub struct DownloadClientInsert { - pub name: String, - pub client_type: String, - pub protocol: String, - pub config: JsonValue, - pub enabled: bool, - pub priority: i32, - /// Dedup key: name + host (from config). - pub dedup_key: String, -} - -#[derive(Debug, Clone)] -pub struct SeriesInsert { - pub old_id: i64, - pub title: String, - pub clean_title: String, - pub sort_title: String, - pub overview: Option, - pub status: String, - pub series_type: String, - pub network: Option, - pub air_time: Option, - pub first_aired: Option, - pub year: Option, - pub runtime: Option, - pub path: String, - pub quality_profile_old_id: i64, - pub season_folder: bool, - pub monitored: bool, - pub use_scene_numbering: bool, - pub tvdb_id: Option, - pub imdb_id: Option, - pub tmdb_id: Option, - pub tvmaze_id: Option, - pub images: Option, - pub tags: Option>, - pub added_at: DateTime, - pub seasons_json: Option, -} - -#[derive(Debug, Clone)] -pub struct EpisodeInsert { - pub old_id: i64, - pub old_series_id: i64, - pub season_number: i32, - pub episode_number: i32, - pub absolute_number: Option, - pub scene_season_number: Option, - pub scene_episode_number: Option, - pub scene_absolute_number: Option, - pub title: Option, - pub overview: Option, - pub air_date: Option, - pub air_date_utc: Option>, - pub runtime: Option, - pub monitored: bool, - pub old_episode_file_id: Option, - pub last_search_time: Option>, -} - -#[derive(Debug, Clone)] -pub struct MediaFileInsert { - pub old_id: i64, - pub media_type: String, - pub relative_path: String, - pub size: i64, - pub date_added: DateTime, - pub quality: JsonValue, - pub languages: JsonValue, - pub scene_name: Option, - pub release_group: Option, - pub release_hash: Option, - pub edition: Option, - pub media_info: Option, - pub indexer_flags: i32, -} - -#[derive(Debug, Clone)] -pub struct MovieInsert { - pub old_id: i64, - pub title: String, - pub clean_title: String, - pub sort_title: String, - pub overview: Option, - pub year: Option, - pub studio: Option, - pub path: String, - pub quality_profile_old_id: i64, - pub monitored: bool, - pub minimum_availability: String, - pub old_movie_file_id: Option, - pub tmdb_id: Option, - pub imdb_id: Option, - pub in_cinemas: Option, - pub physical_release: Option, - pub digital_release: Option, - pub images: Option, - pub genres: Option>, - pub tags: Option>, - pub collection_tmdb_id: Option, - pub added_at: DateTime, - pub original_language: Option, -} - -#[derive(Debug, Clone)] -pub struct HistoryInsert { - pub media_type: String, - /// Old media ID (series_id or movie_id from source). - pub old_media_id: i64, - /// Old episode ID (Sonarr only). - pub old_episode_id: Option, - pub event_type: String, - pub quality: JsonValue, - pub languages: Option, - pub source_title: String, - pub download_id: Option, - pub data: Option, - pub occurred_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct BlocklistInsert { - pub media_type: String, - pub old_media_id: i64, - pub source_title: String, - pub quality: JsonValue, - pub languages: Option, - pub info_hash: Option, - pub added_at: DateTime, -} - -// --------------------------------------------------------------------------- -// Merged migration data -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone)] -pub struct MigrationData { - pub quality_profiles: Vec, - pub media_library_folders: Vec, - pub tags: Vec, - pub naming_series: Option, - pub naming_movie: Option, - pub indexers: Vec, - pub download_clients: Vec, - pub series: Vec, - pub episodes: Vec, - pub media_files: Vec, - pub movies: Vec, - pub history: Vec, - pub blocklist: Vec, -} - -// --------------------------------------------------------------------------- -// Migration report -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MigrationReport { - pub series_imported: usize, - pub movies_imported: usize, - pub episodes_imported: usize, - pub media_files_imported: usize, - pub quality_profiles_imported: usize, - pub indexers_imported: usize, - pub download_clients_imported: usize, - pub history_events_imported: usize, - pub blocklist_entries_imported: usize, - pub warnings: Vec, - pub dry_run: bool, -} - -impl std::fmt::Display for MigrationReport { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!(f, "Migration Report (dry_run={})", self.dry_run)?; - writeln!(f, " Quality profiles: {}", self.quality_profiles_imported)?; - writeln!(f, " Indexers: {}", self.indexers_imported)?; - writeln!(f, " Download clients: {}", self.download_clients_imported)?; - writeln!(f, " Series: {}", self.series_imported)?; - writeln!(f, " Episodes: {}", self.episodes_imported)?; - writeln!(f, " Movies: {}", self.movies_imported)?; - writeln!(f, " Media files: {}", self.media_files_imported)?; - writeln!(f, " History events: {}", self.history_events_imported)?; - writeln!(f, " Blocklist entries: {}", self.blocklist_entries_imported)?; - if !self.warnings.is_empty() { - writeln!(f, " Warnings:")?; - for w in &self.warnings { - writeln!(f, " - {w}")?; - } - } - Ok(()) - } -} - -// --------------------------------------------------------------------------- -// Colon replacement mapping -// --------------------------------------------------------------------------- - -fn colon_replacement_str(val: i32) -> &'static str { - match val { - 0 => "delete", - 1 => "dash", - 2 => "space_dash", - 3 => "space_dash_space", - 4 => "smart", - _ => "smart", - } -} - -// --------------------------------------------------------------------------- -// Build MigrationData from source databases -// --------------------------------------------------------------------------- - -pub fn build_migration_data( - sonarr: Option<&SonarrData>, - radarr: Option<&RadarrData>, - prowlarr: Option<&ProwlarrData>, -) -> (MigrationData, Vec) { - let mut warnings: Vec = Vec::new(); - - // --- Tags (merge by label, case-insensitive) --- - let mut tag_set: Vec = Vec::new(); - let mut seen_tags: HashMap = HashMap::new(); - - let mut add_tags = |labels: &[(i64, String)]| { - for (_, label) in labels { - let lower = label.to_lowercase(); - if !seen_tags.contains_key(&lower) { - seen_tags.insert(lower, tag_set.len()); - tag_set.push(label.clone()); - } - } - }; - - if let Some(s) = sonarr { - let pairs: Vec<_> = s.tags.iter().map(|t| (t.id, t.label.clone())).collect(); - add_tags(&pairs); - } - if let Some(r) = radarr { - let pairs: Vec<_> = r.tags.iter().map(|t| (t.id, t.label.clone())).collect(); - add_tags(&pairs); - } - if let Some(p) = prowlarr { - let pairs: Vec<_> = p.tags.iter().map(|t| (t.id, t.label.clone())).collect(); - add_tags(&pairs); - } - - // --- Quality profiles (Sonarr + Radarr imported separately) --- - // Sonarr and Radarr may share profile names but have different items - // (e.g., Remux-2160p enabled in one but not the other). Import both - // so each media type uses the correct profile version. - let mut profiles: Vec = Vec::new(); - let mut sonarr_profile_names: HashMap = HashMap::new(); - - if let Some(s) = sonarr { - for p in &s.quality_profiles { - let items: JsonValue = - serde_json::from_str(&p.items).unwrap_or(JsonValue::Array(vec![])); - profiles.push(QualityProfileInsert { - name: p.name.clone(), - cutoff: p.cutoff, - upgrade_allowed: p.upgrade_allowed, - min_format_score: p.min_format_score, - cutoff_format_score: p.cutoff_format_score, - items, - old_id: p.id, - media_type: Some("series".to_string()), - language: -1, // Sonarr v4 has no profile-level language filter - }); - sonarr_profile_names.insert(p.name.to_lowercase(), profiles.len() - 1); - } - } - - // Offset Radarr old_ids to avoid collisions with Sonarr old_ids - // in the profile_id_map (both sources can have id=1, id=2, etc.). - const RADARR_PROFILE_OFFSET: i64 = 100_000; - - if let Some(r) = radarr { - for p in &r.quality_profiles { - let raw_items: JsonValue = - serde_json::from_str(&p.items).unwrap_or(JsonValue::Array(vec![])); - // Remap Radarr quality IDs to StackArr/Sonarr numbering - let items = normalize_radarr_quality_ids(&raw_items); - let cutoff = radarr_quality_id_to_stackarr(p.cutoff as i64) as i32; - let lower = p.name.to_lowercase(); - // If a Sonarr profile has the same name, import the Radarr - // version with a " (Movie)" suffix so movies get the correct items. - let name = if sonarr_profile_names.contains_key(&lower) { - format!("{} (Movie)", p.name) - } else { - p.name.clone() - }; - profiles.push(QualityProfileInsert { - name, - cutoff, - upgrade_allowed: p.upgrade_allowed, - min_format_score: p.min_format_score, - cutoff_format_score: p.cutoff_format_score, - items, - old_id: p.id + RADARR_PROFILE_OFFSET, - media_type: Some("movie".to_string()), - language: p.language, - }); - } - } - - // --- Media library folders --- - let mut media_library_folders: Vec = Vec::new(); - let mut seen_root_paths: HashMap = HashMap::new(); - - if let Some(s) = sonarr { - for path in &s.root_folders { - if !seen_root_paths.contains_key(path) { - seen_root_paths.insert(path.clone(), ()); - media_library_folders.push(MediaLibraryFolderInsert { - path: path.clone(), - media_type: "series".to_string(), - }); - } - } - } - if let Some(r) = radarr { - for path in &r.root_folders { - if !seen_root_paths.contains_key(path) { - seen_root_paths.insert(path.clone(), ()); - media_library_folders.push(MediaLibraryFolderInsert { - path: path.clone(), - media_type: "movie".to_string(), - }); - } - } - } - - // --- Naming config --- - let naming_series = sonarr.and_then(|s| { - s.naming_config.as_ref().map(|nc| NamingConfigInsert { - media_type: "series".to_string(), - rename_files: nc.rename_episodes, - standard_format: nc.standard_episode_format.clone(), - daily_format: nc.daily_episode_format.clone(), - anime_format: nc.anime_episode_format.clone(), - season_folder_format: nc.season_folder_format.clone(), - movie_format: None, - movie_folder_format: None, - colon_replacement: colon_replacement_str(nc.colon_replacement_format).to_string(), - }) - }); - - let naming_movie = radarr.and_then(|r| { - r.naming_config.as_ref().map(|nc| NamingConfigInsert { - media_type: "movie".to_string(), - rename_files: nc.rename_movies, - standard_format: None, - daily_format: None, - anime_format: None, - season_folder_format: None, - movie_format: nc.standard_movie_format.clone(), - movie_folder_format: nc.movie_folder_format.clone(), - colon_replacement: colon_replacement_str(nc.colon_replacement_format).to_string(), - }) - }); - - // --- Indexers (Prowlarr takes priority, dedup by base_url) --- - let mut indexers: Vec = Vec::new(); - let mut seen_indexer_urls: HashMap = HashMap::new(); - - if let Some(p) = prowlarr { - for idx in &p.indexers { - let settings = idx - .settings - .as_deref() - .map(parse_prowlarr_settings) - .unwrap_or_default(); - let base_url = settings.base_url.clone().unwrap_or_default(); - if base_url.is_empty() { - warnings.push(format!( - "Prowlarr indexer '{}' has no base URL, skipping", - idx.name - )); - continue; - } - - let protocol = map_prowlarr_protocol(&idx.implementation); - let indexer_type = map_prowlarr_indexer_type(&idx.implementation); - - seen_indexer_urls.insert(base_url.clone(), indexers.len()); - indexers.push(IndexerInsert { - name: idx.name.clone(), - indexer_type: indexer_type.to_string(), - base_url, - api_key: settings.api_key, - protocol: protocol.to_string(), - categories: settings.categories, - enabled: idx.enable, - priority: idx.priority, - supports_search: true, - supports_rss: true, - config: idx - .settings - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()), - }); - } - } - - // Sonarr indexers (dedup against Prowlarr by base_url) - if let Some(s) = sonarr { - for idx in &s.indexers { - let settings = idx - .settings - .as_deref() - .map(parse_indexer_settings) - .unwrap_or_default(); - let base_url = settings.base_url.clone().unwrap_or_default(); - if base_url.is_empty() { - warnings.push(format!( - "Sonarr indexer '{}' has no base URL, skipping", - idx.name - )); - continue; - } - if seen_indexer_urls.contains_key(&base_url) { - continue; // Already imported from Prowlarr - } - - let protocol = map_implementation_to_protocol(&idx.implementation); - - seen_indexer_urls.insert(base_url.clone(), indexers.len()); - indexers.push(IndexerInsert { - name: idx.name.clone(), - indexer_type: idx.implementation.clone(), - base_url, - api_key: settings.api_key, - protocol: protocol.to_string(), - categories: settings.categories, - enabled: idx.enable_rss || idx.enable_automatic_search, - priority: idx.priority, - supports_search: idx.enable_automatic_search || idx.enable_interactive_search, - supports_rss: idx.enable_rss, - config: idx - .settings - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()), - }); - } - } - - // Radarr indexers (dedup against everything) - if let Some(r) = radarr { - for idx in &r.indexers { - let settings = idx - .settings - .as_deref() - .map(parse_indexer_settings) - .unwrap_or_default(); - let base_url = settings.base_url.clone().unwrap_or_default(); - if base_url.is_empty() || seen_indexer_urls.contains_key(&base_url) { - continue; - } - - let protocol = map_implementation_to_protocol(&idx.implementation); - - seen_indexer_urls.insert(base_url.clone(), indexers.len()); - indexers.push(IndexerInsert { - name: idx.name.clone(), - indexer_type: idx.implementation.clone(), - base_url, - api_key: settings.api_key, - protocol: protocol.to_string(), - categories: settings.categories, - enabled: idx.enable_rss || idx.enable_automatic_search, - priority: idx.priority, - supports_search: idx.enable_automatic_search || idx.enable_interactive_search, - supports_rss: idx.enable_rss, - config: idx - .settings - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()), - }); - } - } - - // --- Download clients (dedup by name+host) --- - let mut download_clients: Vec = Vec::new(); - let mut seen_dl_keys: HashMap = HashMap::new(); - - let mut add_download_client = - |name: &str, implementation: &str, settings_json: Option<&str>, enabled: bool, priority: i32| { - let settings = settings_json - .map(parse_download_client_settings) - .unwrap_or_default(); - let host = settings.host.clone().unwrap_or_default(); - let dedup_key = format!("{}:{}", name.to_lowercase(), host.to_lowercase()); - - if seen_dl_keys.contains_key(&dedup_key) { - return; - } - - let protocol = map_dl_implementation_to_protocol(implementation); - let config: JsonValue = settings_json - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - - seen_dl_keys.insert(dedup_key.clone(), download_clients.len()); - download_clients.push(DownloadClientInsert { - name: name.to_string(), - client_type: implementation.to_string(), - protocol: protocol.to_string(), - config, - enabled, - priority, - dedup_key, - }); - }; - - if let Some(s) = sonarr { - for dc in &s.download_clients { - add_download_client( - &dc.name, - &dc.implementation, - dc.settings.as_deref(), - dc.enable, - dc.priority, - ); - } - } - if let Some(r) = radarr { - for dc in &r.download_clients { - add_download_client( - &dc.name, - &dc.implementation, - dc.settings.as_deref(), - dc.enable, - dc.priority, - ); - } - } - - // --- Series --- - let mut series_inserts: Vec = Vec::new(); - if let Some(s) = sonarr { - for sr in &s.series { - let images: Option = sr - .images - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let tags: Option> = sr - .tags - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let added_at = sr - .added - .as_deref() - .and_then(parse_datetime) - .unwrap_or_else(Utc::now); - let first_aired = sr.first_aired.as_deref().and_then(|s| { - parse_date(s).or_else(|| parse_datetime(s).map(|dt| dt.date_naive())) - }); - - series_inserts.push(SeriesInsert { - old_id: sr.id, - title: sr.title.clone(), - clean_title: sr.clean_title.clone(), - sort_title: sr.sort_title.clone(), - overview: sr.overview.clone(), - status: map_series_status(sr.status).to_string(), - series_type: map_series_type(sr.series_type).to_string(), - network: sr.network.clone(), - air_time: sr.air_time.as_deref().and_then(parse_time), - first_aired, - year: sr.year, - runtime: sr.runtime, - path: sr.path.clone(), - quality_profile_old_id: sr.quality_profile_id, - season_folder: sr.season_folder, - monitored: sr.monitored, - use_scene_numbering: sr.use_scene_numbering, - tvdb_id: sr.tvdb_id, - imdb_id: sr.imdb_id.clone(), - tmdb_id: sr.tmdb_id, - tvmaze_id: sr.tvmaze_id, - images, - tags, - added_at, - seasons_json: sr.seasons.clone(), - }); - } - } - - // --- Episodes --- - let mut episode_inserts: Vec = Vec::new(); - if let Some(s) = sonarr { - for ep in &s.episodes { - episode_inserts.push(EpisodeInsert { - old_id: ep.id, - old_series_id: ep.series_id, - season_number: ep.season_number, - episode_number: ep.episode_number, - absolute_number: ep.absolute_episode_number, - scene_season_number: ep.scene_season_number, - scene_episode_number: ep.scene_episode_number, - scene_absolute_number: ep.scene_absolute_episode_number, - title: ep.title.clone(), - overview: ep.overview.clone(), - air_date: ep.air_date.as_deref().and_then(parse_date), - air_date_utc: ep.air_date_utc.as_deref().and_then(parse_datetime), - runtime: ep.runtime, - monitored: ep.monitored, - old_episode_file_id: ep.episode_file_id, - last_search_time: ep.last_search_time.as_deref().and_then(parse_datetime), - }); - } - } - - // --- Media files (episodes + movies) --- - let mut media_file_inserts: Vec = Vec::new(); - - if let Some(s) = sonarr { - for ef in &s.episode_files { - let quality: JsonValue = ef - .quality - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - let languages: JsonValue = ef - .languages - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Array(vec![])); - let media_info: Option = ef - .media_info - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let date_added = ef - .date_added - .as_deref() - .and_then(parse_datetime) - .unwrap_or_else(Utc::now); - - media_file_inserts.push(MediaFileInsert { - old_id: ef.id, - media_type: "series".to_string(), - relative_path: ef.relative_path.clone().unwrap_or_default(), - size: ef.size, - date_added, - quality, - languages, - scene_name: ef.scene_name.clone(), - release_group: ef.release_group.clone(), - release_hash: ef.release_hash.clone(), - edition: None, - media_info, - indexer_flags: ef.indexer_flags, - }); - } - } - - if let Some(r) = radarr { - for mf in &r.movie_files { - let quality: JsonValue = mf - .quality - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - let languages: JsonValue = mf - .languages - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Array(vec![])); - let media_info: Option = mf - .media_info - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let date_added = mf - .date_added - .as_deref() - .and_then(parse_datetime) - .unwrap_or_else(Utc::now); - - media_file_inserts.push(MediaFileInsert { - old_id: mf.id, - media_type: "movie".to_string(), - relative_path: mf.relative_path.clone().unwrap_or_default(), - size: mf.size, - date_added, - quality, - languages, - scene_name: mf.scene_name.clone(), - release_group: mf.release_group.clone(), - release_hash: None, - edition: mf.edition.clone(), - media_info, - indexer_flags: mf.indexer_flags, - }); - } - } - - // --- Movies --- - let mut movie_inserts: Vec = Vec::new(); - if let Some(r) = radarr { - for mv in &r.movies { - let images: Option = mv - .images - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let genres: Option> = mv - .genres - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let tags: Option> = mv - .tags - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let added_at = mv - .added - .as_deref() - .and_then(parse_datetime) - .unwrap_or_else(Utc::now); - let in_cinemas = mv.in_cinemas.as_deref().and_then(|s| { - parse_date(s).or_else(|| parse_datetime(s).map(|dt| dt.date_naive())) - }); - let physical_release = mv.physical_release.as_deref().and_then(|s| { - parse_date(s).or_else(|| parse_datetime(s).map(|dt| dt.date_naive())) - }); - let digital_release = mv.digital_release.as_deref().and_then(|s| { - parse_date(s).or_else(|| parse_datetime(s).map(|dt| dt.date_naive())) - }); - - movie_inserts.push(MovieInsert { - old_id: mv.id, - title: mv.title.clone(), - clean_title: mv.clean_title.clone(), - sort_title: mv.sort_title.clone(), - overview: mv.overview.clone(), - year: mv.year, - studio: mv.studio.clone(), - path: mv.path.clone(), - quality_profile_old_id: mv.quality_profile_id + RADARR_PROFILE_OFFSET, - monitored: mv.monitored, - minimum_availability: map_minimum_availability(mv.minimum_availability) - .to_string(), - old_movie_file_id: mv.movie_file_id, - tmdb_id: mv.tmdb_id, - imdb_id: mv.imdb_id.clone(), - in_cinemas, - physical_release, - digital_release, - images, - genres, - tags, - collection_tmdb_id: mv.collection_tmdb_id, - added_at, - original_language: mv.original_language, - }); - } - } - - // --- History --- - let mut history_inserts: Vec = Vec::new(); - - if let Some(s) = sonarr { - for h in &s.history { - let quality: JsonValue = h - .quality - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - let languages: Option = h - .languages - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let data: Option = h - .data - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let occurred_at = parse_datetime(&h.date).unwrap_or_else(Utc::now); - - history_inserts.push(HistoryInsert { - media_type: "series".to_string(), - old_media_id: h.series_id, - old_episode_id: Some(h.episode_id), - event_type: map_event_type(h.event_type).to_string(), - quality, - languages, - source_title: h.source_title.clone(), - download_id: h.download_id.clone(), - data, - occurred_at, - }); - } - } - - if let Some(r) = radarr { - for h in &r.history { - let quality: JsonValue = h - .quality - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - let languages: Option = h - .languages - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let data: Option = h - .data - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let occurred_at = parse_datetime(&h.date).unwrap_or_else(Utc::now); - - history_inserts.push(HistoryInsert { - media_type: "movie".to_string(), - old_media_id: h.movie_id, - old_episode_id: None, - event_type: map_event_type(h.event_type).to_string(), - quality, - languages, - source_title: h.source_title.clone(), - download_id: h.download_id.clone(), - data, - occurred_at, - }); - } - } - - // --- Blocklist --- - let mut blocklist_inserts: Vec = Vec::new(); - - if let Some(s) = sonarr { - for b in &s.blocklist { - let quality: JsonValue = b - .quality - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - let languages: Option = b - .languages - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let added_at = b - .date - .as_deref() - .and_then(parse_datetime) - .unwrap_or_else(Utc::now); - - blocklist_inserts.push(BlocklistInsert { - media_type: "series".to_string(), - old_media_id: b.series_id, - source_title: b.source_title.clone(), - quality, - languages, - info_hash: b.torrent_info_hash.clone(), - added_at, - }); - } - } - - if let Some(r) = radarr { - for b in &r.blocklist { - let quality: JsonValue = b - .quality - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(JsonValue::Object(serde_json::Map::new())); - let languages: Option = b - .languages - .as_deref() - .and_then(|s| serde_json::from_str(s).ok()); - let added_at = b - .date - .as_deref() - .and_then(parse_datetime) - .unwrap_or_else(Utc::now); - - blocklist_inserts.push(BlocklistInsert { - media_type: "movie".to_string(), - old_media_id: b.movie_id, - source_title: b.source_title.clone(), - quality, - languages, - info_hash: b.torrent_info_hash.clone(), - added_at, - }); - } - } - - let data = MigrationData { - quality_profiles: profiles, - media_library_folders, - tags: tag_set, - naming_series, - naming_movie, - indexers, - download_clients, - series: series_inserts, - episodes: episode_inserts, - media_files: media_file_inserts, - movies: movie_inserts, - history: history_inserts, - blocklist: blocklist_inserts, - }; - - (data, warnings) -} - -// --------------------------------------------------------------------------- -// Migration writer -// --------------------------------------------------------------------------- - -pub struct MigrationWriter { - pool: PgPool, -} - -impl MigrationWriter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn write_all(&self, data: MigrationData) -> Result { - let mut report = MigrationReport { - series_imported: 0, - movies_imported: 0, - episodes_imported: 0, - media_files_imported: 0, - quality_profiles_imported: 0, - indexers_imported: 0, - download_clients_imported: 0, - history_events_imported: 0, - blocklist_entries_imported: 0, - warnings: Vec::new(), - dry_run: false, - }; - - // All writes happen in one transaction for atomicity. - let mut tx = self.pool.begin().await.context("begin transaction")?; - - // 1. Tags - let tag_id_map = self.write_tags(&mut tx, &data.tags).await?; - debug!("wrote {} tags", tag_id_map.len()); - - // 2. Quality profiles (map old_id -> new_id) - let profile_id_map = self - .write_quality_profiles(&mut tx, &data.quality_profiles) - .await?; - report.quality_profiles_imported = profile_id_map.len(); - debug!("wrote {} quality profiles", profile_id_map.len()); - - // Build a name->new_id map for Radarr profiles that were deduped by name. - let profile_name_map: HashMap = { - let mut m = HashMap::new(); - for p in &data.quality_profiles { - if let Some(&new_id) = profile_id_map.get(&p.old_id) { - m.insert(p.name.to_lowercase(), new_id); - } - } - m - }; - - // 3. Media library folders - let media_library_folder_id_map = self.write_media_library_folders(&mut tx, &data.media_library_folders).await?; - debug!("wrote {} media library folders", media_library_folder_id_map.len()); - - // 4. Naming config - if let Some(ref nc) = data.naming_series { - self.write_naming_config(&mut tx, nc).await?; - } - if let Some(ref nc) = data.naming_movie { - self.write_naming_config(&mut tx, nc).await?; - } - - // 5. Indexers - let indexer_count = self.write_indexers(&mut tx, &data.indexers).await?; - report.indexers_imported = indexer_count; - - // 6. Download clients - let dl_count = self - .write_download_clients(&mut tx, &data.download_clients) - .await?; - report.download_clients_imported = dl_count; - - // 7. Series - let series_id_map = self - .write_series( - &mut tx, - &data.series, - &profile_id_map, - &profile_name_map, - &media_library_folder_id_map, - &tag_id_map, - ) - .await?; - report.series_imported = series_id_map.len(); - debug!("wrote {} series", series_id_map.len()); - - // 8. Media files (before episodes, because episodes reference file IDs) - // We need separate maps for series files vs movie files because old_ids - // can collide between Sonarr EpisodeFiles and Radarr MovieFiles. - let (series_file_id_map, movie_file_id_map) = self - .write_media_files(&mut tx, &data.media_files) - .await?; - report.media_files_imported = series_file_id_map.len() + movie_file_id_map.len(); - debug!( - "wrote {} media files ({} series, {} movie)", - report.media_files_imported, - series_file_id_map.len(), - movie_file_id_map.len() - ); - - // 9. Episodes (with mapped series_id and episode_file_id) - let episode_id_map = self - .write_episodes( - &mut tx, - &data.episodes, - &series_id_map, - &series_file_id_map, - ) - .await?; - report.episodes_imported = episode_id_map.len(); - - // 10. Seasons (extracted from series JSON) - self.write_seasons(&mut tx, &data.series, &series_id_map) - .await?; - - // 11. Movies - let movie_id_map = self - .write_movies( - &mut tx, - &data.movies, - &profile_id_map, - &profile_name_map, - &media_library_folder_id_map, - &movie_file_id_map, - &tag_id_map, - ) - .await?; - report.movies_imported = movie_id_map.len(); - - // 12. History - let history_count = self - .write_history( - &mut tx, - &data.history, - &series_id_map, - &movie_id_map, - &episode_id_map, - ) - .await?; - report.history_events_imported = history_count; - - // 13. Blocklist - let blocklist_count = self - .write_blocklist(&mut tx, &data.blocklist, &series_id_map, &movie_id_map) - .await?; - report.blocklist_entries_imported = blocklist_count; - - tx.commit().await.context("commit transaction")?; - info!("migration committed successfully"); - - Ok(report) - } - - // -- Tag writer -- - - async fn write_tags( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - tags: &[String], - ) -> Result> { - let mut map = HashMap::new(); - for label in tags { - let row: (i32,) = sqlx::query_as( - "INSERT INTO tags (label) VALUES ($1) - ON CONFLICT (label) DO UPDATE SET label = tags.label - RETURNING id", - ) - .bind(label) - .fetch_one(&mut **tx) - .await - .with_context(|| format!("insert tag '{label}'"))?; - map.insert(label.to_lowercase(), row.0 as i64); - } - Ok(map) - } - - // -- Quality profile writer -- - - async fn write_quality_profiles( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - profiles: &[QualityProfileInsert], - ) -> Result> { - let mut map = HashMap::new(); - for p in profiles { - let row: (i32,) = sqlx::query_as( - "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items, media_type, language) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id", - ) - .bind(&p.name) - .bind(p.cutoff) - .bind(p.upgrade_allowed) - .bind(p.min_format_score) - .bind(p.cutoff_format_score) - .bind(&p.items) - .bind(&p.media_type) - .bind(p.language) - .fetch_one(&mut **tx) - .await - .with_context(|| format!("insert quality profile '{}'", p.name))?; - map.insert(p.old_id, row.0 as i64); - } - Ok(map) - } - - // -- Media library folder writer -- - - async fn write_media_library_folders( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - folders: &[MediaLibraryFolderInsert], - ) -> Result> { - let mut map = HashMap::new(); - for f in folders { - let row: (i32,) = sqlx::query_as( - "INSERT INTO media_library_folders (path, media_type) - VALUES ($1, $2) - ON CONFLICT (path) DO UPDATE SET media_type = media_library_folders.media_type - RETURNING id", - ) - .bind(&f.path) - .bind(&f.media_type) - .fetch_one(&mut **tx) - .await - .with_context(|| format!("insert media library folder '{}'", f.path))?; - map.insert(f.path.clone(), row.0 as i64); - } - Ok(map) - } - - // -- Naming config writer -- - - async fn write_naming_config( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - nc: &NamingConfigInsert, - ) -> Result<()> { - sqlx::query( - "INSERT INTO naming_config (media_type, rename_files, standard_format, daily_format, anime_format, season_folder_format, movie_format, movie_folder_format, colon_replacement) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (media_type) DO UPDATE SET - rename_files = $2, - standard_format = $3, - daily_format = $4, - anime_format = $5, - season_folder_format = $6, - movie_format = $7, - movie_folder_format = $8, - colon_replacement = $9", - ) - .bind(&nc.media_type) - .bind(nc.rename_files) - .bind(&nc.standard_format) - .bind(&nc.daily_format) - .bind(&nc.anime_format) - .bind(&nc.season_folder_format) - .bind(&nc.movie_format) - .bind(&nc.movie_folder_format) - .bind(&nc.colon_replacement) - .execute(&mut **tx) - .await - .with_context(|| format!("upsert naming config for '{}'", nc.media_type))?; - Ok(()) - } - - // -- Indexer writer -- - - async fn write_indexers( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - indexers: &[IndexerInsert], - ) -> Result { - let mut count = 0; - for idx in indexers { - sqlx::query( - "INSERT INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, enabled, priority, supports_search, supports_rss, config) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", - ) - .bind(&idx.name) - .bind(&idx.indexer_type) - .bind(&idx.base_url) - .bind(&idx.api_key) - .bind(&idx.protocol) - .bind(&idx.categories) - .bind(idx.enabled) - .bind(idx.priority) - .bind(idx.supports_search) - .bind(idx.supports_rss) - .bind(&idx.config) - .execute(&mut **tx) - .await - .with_context(|| format!("insert indexer '{}'", idx.name))?; - count += 1; - } - Ok(count) - } - - // -- Download client writer -- - - async fn write_download_clients( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - clients: &[DownloadClientInsert], - ) -> Result { - let mut count = 0; - for dc in clients { - sqlx::query( - "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, $2, $3, $4, $5, $6)", - ) - .bind(&dc.name) - .bind(&dc.client_type) - .bind(&dc.protocol) - .bind(&dc.config) - .bind(dc.enabled) - .bind(dc.priority) - .execute(&mut **tx) - .await - .with_context(|| format!("insert download client '{}'", dc.name))?; - count += 1; - } - Ok(count) - } - - // -- Series writer -- - - async fn write_series( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - series: &[SeriesInsert], - profile_id_map: &HashMap, - _profile_name_map: &HashMap, - media_library_folder_map: &HashMap, - _tag_id_map: &HashMap, - ) -> Result> { - let mut map = HashMap::new(); - for s in series { - let quality_profile_id = profile_id_map - .get(&s.quality_profile_old_id) - .copied() - .unwrap_or(1); // fallback to first profile - - // Resolve media_library_folder_id from the series path - let media_library_folder_id = media_library_folder_map - .iter() - .find(|(path, _)| s.path.starts_with(path.as_str())) - .map(|(_, &id)| id); - - // Map old tag IDs to new tag IDs - let mapped_tags: Option> = s.tags.as_ref().map(|old_tags| { - // Old tags are integer IDs; we need to look up by label which we don't have here. - // For now, pass them through -- they will need re-mapping in a real scenario. - // Since tags are small integers and we insert them in order, this is approximate. - old_tags.clone() - }); - - let row: (i64,) = sqlx::query_as( - "INSERT INTO series (title, clean_title, sort_title, overview, status, series_type, - network, air_time, first_aired, year, runtime, path, media_library_folder_id, - quality_profile_id, season_folder, monitored, use_scene_numbering, - tvdb_id, imdb_id, tmdb_id, tvmaze_id, images, genres, tags, added_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) - RETURNING id", - ) - .bind(&s.title) - .bind(&s.clean_title) - .bind(&s.sort_title) - .bind(&s.overview) - .bind(&s.status) - .bind(&s.series_type) - .bind(&s.network) - .bind(s.air_time) - .bind(s.first_aired) - .bind(s.year) - .bind(s.runtime) - .bind(&s.path) - .bind(media_library_folder_id) - .bind(quality_profile_id) - .bind(s.season_folder) - .bind(s.monitored) - .bind(s.use_scene_numbering) - .bind(s.tvdb_id) - .bind(&s.imdb_id) - .bind(s.tmdb_id) - .bind(s.tvmaze_id) - .bind(&s.images) - .bind::>(None) // genres - Sonarr doesn't store them on Series - .bind(mapped_tags.as_deref()) - .bind(s.added_at) - .fetch_one(&mut **tx) - .await - .with_context(|| format!("insert series '{}'", s.title))?; - - map.insert(s.old_id, row.0); - } - Ok(map) - } - - // -- Seasons writer -- - - async fn write_seasons( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - series: &[SeriesInsert], - series_id_map: &HashMap, - ) -> Result<()> { - for s in series { - let Some(&new_series_id) = series_id_map.get(&s.old_id) else { - continue; - }; - if let Some(ref json) = s.seasons_json { - let seasons = parse_seasons_json(json); - for season in seasons { - let result = sqlx::query( - "INSERT INTO seasons (series_id, season_number, monitored) - VALUES ($1, $2, $3) - ON CONFLICT (series_id, season_number) DO NOTHING", - ) - .bind(new_series_id) - .bind(season.season_number) - .bind(season.monitored) - .execute(&mut **tx) - .await; - - if let Err(e) = result { - warn!("failed to insert season {}/{}: {e}", new_series_id, season.season_number); - } - } - } - } - Ok(()) - } - - // -- Media files writer -- - - async fn write_media_files( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - files: &[MediaFileInsert], - ) -> Result<(HashMap, HashMap)> { - let mut series_map = HashMap::new(); - let mut movie_map = HashMap::new(); - - for f in files { - let row: (i64,) = sqlx::query_as( - "INSERT INTO media_files (media_type, relative_path, size, date_added, quality, - languages, scene_name, release_group, release_hash, edition, media_info, indexer_flags) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING id", - ) - .bind(&f.media_type) - .bind(&f.relative_path) - .bind(f.size) - .bind(f.date_added) - .bind(&f.quality) - .bind(&f.languages) - .bind(&f.scene_name) - .bind(&f.release_group) - .bind(&f.release_hash) - .bind(&f.edition) - .bind(&f.media_info) - .bind(f.indexer_flags) - .fetch_one(&mut **tx) - .await - .with_context(|| format!("insert media file '{}'", f.relative_path))?; - - match f.media_type.as_str() { - "series" => { - series_map.insert(f.old_id, row.0); - } - "movie" => { - movie_map.insert(f.old_id, row.0); - } - _ => {} - } - } - - Ok((series_map, movie_map)) - } - - // -- Episodes writer -- - - async fn write_episodes( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - episodes: &[EpisodeInsert], - series_id_map: &HashMap, - file_id_map: &HashMap, - ) -> Result> { - let mut map = HashMap::new(); - - for ep in episodes { - let Some(&new_series_id) = series_id_map.get(&ep.old_series_id) else { - warn!( - "episode {} references unknown series {}, skipping", - ep.old_id, ep.old_series_id - ); - continue; - }; - - let episode_file_id = ep - .old_episode_file_id - .and_then(|old_id| file_id_map.get(&old_id).copied()); - - let result = sqlx::query_as::<_, (i64,)>( - "INSERT INTO episodes (series_id, season_number, episode_number, absolute_number, - scene_season_number, scene_episode_number, scene_absolute_number, - title, overview, air_date, air_date_utc, runtime, monitored, - episode_file_id, last_search_time) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - ON CONFLICT (series_id, season_number, episode_number) DO NOTHING - RETURNING id", - ) - .bind(new_series_id) - .bind(ep.season_number) - .bind(ep.episode_number) - .bind(ep.absolute_number) - .bind(ep.scene_season_number) - .bind(ep.scene_episode_number) - .bind(ep.scene_absolute_number) - .bind(&ep.title) - .bind(&ep.overview) - .bind(ep.air_date) - .bind(ep.air_date_utc) - .bind(ep.runtime) - .bind(ep.monitored) - .bind(episode_file_id) - .bind(ep.last_search_time) - .fetch_optional(&mut **tx) - .await - .with_context(|| { - format!( - "insert episode S{:02}E{:02} for series {}", - ep.season_number, ep.episode_number, new_series_id - ) - })?; - - if let Some((new_id,)) = result { - map.insert(ep.old_id, new_id); - - // Also insert into episode_files join table if there is a file - if let Some(new_file_id) = episode_file_id { - let _ = sqlx::query( - "INSERT INTO episode_files (episode_id, media_file_id) - VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(new_id) - .bind(new_file_id) - .execute(&mut **tx) - .await; - } - } - } - - Ok(map) - } - - // -- Movies writer -- - - #[allow(clippy::too_many_arguments)] - async fn write_movies( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - movies: &[MovieInsert], - profile_id_map: &HashMap, - profile_name_map: &HashMap, - media_library_folder_map: &HashMap, - file_id_map: &HashMap, - _tag_id_map: &HashMap, - ) -> Result> { - let mut map = HashMap::new(); - - for m in movies { - // Resolve quality profile via direct ID map (Radarr old_ids are offset - // by RADARR_PROFILE_OFFSET to avoid collisions with Sonarr). - let quality_profile_id = profile_id_map - .get(&m.quality_profile_old_id) - .copied() - .or_else(|| profile_name_map.values().next().copied()) - .unwrap_or(1); - - let media_library_folder_id = media_library_folder_map - .iter() - .find(|(path, _)| m.path.starts_with(path.as_str())) - .map(|(_, &id)| id); - - let movie_file_id = m - .old_movie_file_id - .and_then(|old_id| file_id_map.get(&old_id).copied()); - - let row: (i64,) = sqlx::query_as( - "INSERT INTO movies (title, clean_title, sort_title, overview, year, studio, - path, media_library_folder_id, quality_profile_id, monitored, minimum_availability, - movie_file_id, tmdb_id, imdb_id, in_cinemas, physical_release, - digital_release, images, genres, tags, collection_tmdb_id, added_at, original_language) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, - $15, $16, $17, $18, $19, $20, $21, $22, $23) - RETURNING id", - ) - .bind(&m.title) - .bind(&m.clean_title) - .bind(&m.sort_title) - .bind(&m.overview) - .bind(m.year) - .bind(&m.studio) - .bind(&m.path) - .bind(media_library_folder_id) - .bind(quality_profile_id) - .bind(m.monitored) - .bind(&m.minimum_availability) - .bind(movie_file_id) - .bind(m.tmdb_id) - .bind(&m.imdb_id) - .bind(m.in_cinemas) - .bind(m.physical_release) - .bind(m.digital_release) - .bind(&m.images) - .bind(m.genres.as_deref()) - .bind(m.tags.as_deref()) - .bind(m.collection_tmdb_id) - .bind(m.added_at) - .bind(m.original_language) - .fetch_one(&mut **tx) - .await - .with_context(|| format!("insert movie '{}'", m.title))?; - - map.insert(m.old_id, row.0); - } - - Ok(map) - } - - // -- History writer -- - - async fn write_history( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - history: &[HistoryInsert], - series_id_map: &HashMap, - movie_id_map: &HashMap, - episode_id_map: &HashMap, - ) -> Result { - let mut count = 0; - - for h in history { - let media_id = match h.media_type.as_str() { - "series" => series_id_map.get(&h.old_media_id).copied(), - "movie" => movie_id_map.get(&h.old_media_id).copied(), - _ => None, - }; - - let Some(media_id) = media_id else { - // Referenced media was not imported; skip this history event. - continue; - }; - - let episode_id = h - .old_episode_id - .and_then(|old_id| episode_id_map.get(&old_id).copied()); - - sqlx::query( - "INSERT INTO history (media_type, media_id, episode_id, event_type, quality, - languages, source_title, download_id, data, occurred_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", - ) - .bind(&h.media_type) - .bind(media_id) - .bind(episode_id) - .bind(&h.event_type) - .bind(&h.quality) - .bind(&h.languages) - .bind(&h.source_title) - .bind(&h.download_id) - .bind(&h.data) - .bind(h.occurred_at) - .execute(&mut **tx) - .await - .with_context(|| { - format!( - "insert history event '{}' for {} {}", - h.event_type, h.media_type, media_id - ) - })?; - - count += 1; - } - - Ok(count) - } - - // -- Blocklist writer -- - - async fn write_blocklist( - &self, - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - blocklist: &[BlocklistInsert], - series_id_map: &HashMap, - movie_id_map: &HashMap, - ) -> Result { - let mut count = 0; - - for b in blocklist { - let media_id = match b.media_type.as_str() { - "series" => series_id_map.get(&b.old_media_id).copied(), - "movie" => movie_id_map.get(&b.old_media_id).copied(), - _ => None, - }; - - let Some(media_id) = media_id else { - continue; - }; - - sqlx::query( - "INSERT INTO blocklist (media_type, media_id, source_title, quality, languages, - info_hash, added_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(&b.media_type) - .bind(media_id) - .bind(&b.source_title) - .bind(&b.quality) - .bind(&b.languages) - .bind(&b.info_hash) - .bind(b.added_at) - .execute(&mut **tx) - .await - .with_context(|| { - format!( - "insert blocklist entry '{}' for {} {}", - b.source_title, b.media_type, media_id - ) - })?; - - count += 1; - } - - Ok(count) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/tests/import_fixtures.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/tests/import_fixtures.rs deleted file mode 100644 index d14bfb17..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-migrate/tests/import_fixtures.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Integration test: import real Sonarr, Radarr, Prowlarr backups. -//! -//! Requires: -//! - A running Postgres on localhost:5433 (docker compose -f docker/docker-compose.dev.yml up -d) -//! - Fixture files in test-fixtures/ at the repo root -//! -//! Run with: cargo test -p stackarr-migrate --test import_fixtures -- --ignored --nocapture - -use std::path::PathBuf; - -fn fixtures_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join("test-fixtures") -} - -fn has_fixtures() -> bool { - let dir = fixtures_dir(); - dir.join("sonarr.db").exists() - && dir.join("radarr.db").exists() - && dir.join("prowlarr.db").exists() -} - -#[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] -async fn test_import_all_fixtures() { - if !has_fixtures() { - eprintln!("SKIP: test-fixtures/*.db not found — run from repo root after extracting backups"); - return; - } - - let db = stackarr_core::test_helpers::TestDb::new().await; - let dir = fixtures_dir(); - - let report = stackarr_migrate::run_migration( - &db.pool, - Some(dir.join("sonarr.db").as_path()), - Some(dir.join("radarr.db").as_path()), - Some(dir.join("prowlarr.db").as_path()), - false, - ) - .await - .expect("migration should succeed"); - - eprintln!("{report}"); - - assert!(report.series_imported > 0, "should import series"); - assert!(report.movies_imported > 0, "should import movies"); - assert!(report.episodes_imported > 0, "should import episodes"); - assert!(report.quality_profiles_imported > 0, "should import quality profiles"); - assert!(report.indexers_imported > 0, "should import indexers"); - - // Verify data landed in Postgres - let series_count: (i64,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM series") - .fetch_one(&db.pool) - .await - .expect("count series"); - assert_eq!(series_count.0 as usize, report.series_imported); - - let movie_count: (i64,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM movies") - .fetch_one(&db.pool) - .await - .expect("count movies"); - assert_eq!(movie_count.0 as usize, report.movies_imported); - - let episode_count: (i64,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM episodes") - .fetch_one(&db.pool) - .await - .expect("count episodes"); - assert_eq!(episode_count.0 as usize, report.episodes_imported); - - let tag_count: (i64,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM tags") - .fetch_one(&db.pool) - .await - .expect("count tags"); - assert!(tag_count.0 > 0, "should have tags"); - - if !report.warnings.is_empty() { - eprintln!("Warnings ({}):", report.warnings.len()); - for w in &report.warnings { - eprintln!(" - {w}"); - } - } - - db.cleanup().await; -} - -#[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] -async fn test_import_sonarr_only() { - if !has_fixtures() { - eprintln!("SKIP: test-fixtures/sonarr.db not found"); - return; - } - - let db = stackarr_core::test_helpers::TestDb::new().await; - let dir = fixtures_dir(); - - let report = stackarr_migrate::run_migration( - &db.pool, - Some(dir.join("sonarr.db").as_path()), - None, - None, - false, - ) - .await - .expect("sonarr-only migration should succeed"); - - eprintln!("{report}"); - assert!(report.series_imported > 0); - assert!(report.episodes_imported > 0); - assert_eq!(report.movies_imported, 0); - - db.cleanup().await; -} - -#[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] -async fn test_import_radarr_only() { - if !has_fixtures() { - eprintln!("SKIP: test-fixtures/radarr.db not found"); - return; - } - - let db = stackarr_core::test_helpers::TestDb::new().await; - let dir = fixtures_dir(); - - let report = stackarr_migrate::run_migration( - &db.pool, - None, - Some(dir.join("radarr.db").as_path()), - None, - false, - ) - .await - .expect("radarr-only migration should succeed"); - - eprintln!("{report}"); - assert!(report.movies_imported > 0); - assert_eq!(report.series_imported, 0); - - db.cleanup().await; -} - -#[tokio::test] -#[ignore = "requires running postgres and test-fixtures"] -async fn test_dry_run() { - if !has_fixtures() { - eprintln!("SKIP: test-fixtures not found"); - return; - } - - let db = stackarr_core::test_helpers::TestDb::new().await; - let dir = fixtures_dir(); - - let report = stackarr_migrate::run_migration( - &db.pool, - Some(dir.join("sonarr.db").as_path()), - Some(dir.join("radarr.db").as_path()), - Some(dir.join("prowlarr.db").as_path()), - true, - ) - .await - .expect("dry run should succeed"); - - assert!(report.dry_run); - assert!(report.series_imported > 0, "dry run should count series"); - - // Verify nothing was written - let count: (i64,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM series") - .fetch_one(&db.pool) - .await - .expect("count"); - assert_eq!(count.0, 0, "dry run should not write data"); - - db.cleanup().await; -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-notify/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-notify/Cargo.toml deleted file mode 100644 index 58829fc2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-notify/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "stackarr-notify" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -reqwest = { workspace = true } -async-trait = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } - -[dev-dependencies] -wiremock = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-notify/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-notify/src/lib.rs deleted file mode 100644 index 9bd37ec9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-notify/src/lib.rs +++ /dev/null @@ -1,681 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -// ── Events ────────────────────────────────────────────────────────────────── - -/// Events that can trigger notifications. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum NotificationEvent { - Grab { - title: String, - quality: String, - indexer: String, - }, - Import { - title: String, - quality: String, - }, - Upgrade { - title: String, - old_quality: String, - new_quality: String, - }, - HealthIssue { - source: String, - message: String, - }, - DownloadFailure { - title: String, - message: String, - }, -} - -impl NotificationEvent { - /// Short human-readable summary of the event. - pub fn summary(&self) -> String { - match self { - Self::Grab { title, quality, .. } => format!("Grabbed: {title} [{quality}]"), - Self::Import { title, quality } => format!("Imported: {title} [{quality}]"), - Self::Upgrade { - title, - new_quality, - .. - } => format!("Upgraded: {title} [{new_quality}]"), - Self::HealthIssue { source, message } => format!("Health: {source} - {message}"), - Self::DownloadFailure { title, message } => { - format!("Failed: {title} - {message}") - } - } - } -} - -// ── Provider trait ────────────────────────────────────────────────────────── - -#[async_trait::async_trait] -pub trait NotificationProvider: Send + Sync { - /// Human-readable name for this provider type. - fn name(&self) -> &str; - - /// Send a notification event. - async fn send(&self, event: &NotificationEvent) -> Result<()>; - - /// Test the connection / configuration. - async fn test(&self) -> Result<()>; -} - -// ── Webhook provider ──────────────────────────────────────────────────────── - -pub struct WebhookProvider { - client: reqwest::Client, - url: String, -} - -impl WebhookProvider { - pub fn new(url: String) -> Self { - Self { - client: reqwest::Client::new(), - url, - } - } -} - -#[async_trait::async_trait] -impl NotificationProvider for WebhookProvider { - fn name(&self) -> &str { - "Webhook" - } - - async fn send(&self, event: &NotificationEvent) -> Result<()> { - tracing::debug!(url = %self.url, "sending webhook notification"); - self.client - .post(&self.url) - .json(event) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - async fn test(&self) -> Result<()> { - let test_event = NotificationEvent::HealthIssue { - source: "test".to_string(), - message: "This is a test notification from StackArr".to_string(), - }; - self.send(&test_event).await - } -} - -// ── Discord provider ──────────────────────────────────────────────────────── - -pub struct DiscordProvider { - client: reqwest::Client, - webhook_url: String, -} - -impl DiscordProvider { - pub fn new(webhook_url: String) -> Self { - Self { - client: reqwest::Client::new(), - webhook_url, - } - } -} - -#[async_trait::async_trait] -impl NotificationProvider for DiscordProvider { - fn name(&self) -> &str { - "Discord" - } - - async fn send(&self, event: &NotificationEvent) -> Result<()> { - tracing::debug!("sending discord notification"); - let body = serde_json::json!({ - "content": event.summary(), - }); - self.client - .post(&self.webhook_url) - .json(&body) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - async fn test(&self) -> Result<()> { - let test_event = NotificationEvent::HealthIssue { - source: "test".to_string(), - message: "This is a test notification from StackArr".to_string(), - }; - self.send(&test_event).await - } -} - -// ── Telegram provider ────────────────────────────────────────────────────── - -pub struct TelegramProvider { - client: reqwest::Client, - bot_token: String, - chat_id: String, -} - -impl TelegramProvider { - pub fn new(bot_token: String, chat_id: String) -> Self { - Self { - client: reqwest::Client::new(), - bot_token, - chat_id, - } - } -} - -#[async_trait::async_trait] -impl NotificationProvider for TelegramProvider { - fn name(&self) -> &str { - "Telegram" - } - - async fn send(&self, event: &NotificationEvent) -> Result<()> { - tracing::debug!("sending telegram notification"); - let url = format!( - "https://api.telegram.org/bot{}/sendMessage", - self.bot_token - ); - let body = serde_json::json!({ - "chat_id": self.chat_id, - "text": event.summary(), - "parse_mode": "HTML", - }); - self.client - .post(&url) - .json(&body) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - async fn test(&self) -> Result<()> { - let test_event = NotificationEvent::HealthIssue { - source: "test".to_string(), - message: "This is a test notification from StackArr".to_string(), - }; - self.send(&test_event).await - } -} - -// ── Slack provider ───────────────────────────────────────────────────────── - -pub struct SlackProvider { - client: reqwest::Client, - webhook_url: String, -} - -impl SlackProvider { - pub fn new(webhook_url: String) -> Self { - Self { - client: reqwest::Client::new(), - webhook_url, - } - } -} - -#[async_trait::async_trait] -impl NotificationProvider for SlackProvider { - fn name(&self) -> &str { - "Slack" - } - - async fn send(&self, event: &NotificationEvent) -> Result<()> { - tracing::debug!("sending slack notification"); - let body = serde_json::json!({ - "text": event.summary(), - }); - self.client - .post(&self.webhook_url) - .json(&body) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - async fn test(&self) -> Result<()> { - let test_event = NotificationEvent::HealthIssue { - source: "test".to_string(), - message: "This is a test notification from StackArr".to_string(), - }; - self.send(&test_event).await - } -} - -// ── Email provider ───────────────────────────────────────────────────────── - -pub struct EmailProvider { - client: reqwest::Client, - smtp_url: String, - from: String, - to: String, -} - -impl EmailProvider { - pub fn new(smtp_url: String, from: String, to: String) -> Self { - Self { - client: reqwest::Client::new(), - smtp_url, - from, - to, - } - } -} - -#[async_trait::async_trait] -impl NotificationProvider for EmailProvider { - fn name(&self) -> &str { - "Email" - } - - async fn send(&self, event: &NotificationEvent) -> Result<()> { - tracing::debug!("sending email notification"); - let body = serde_json::json!({ - "from": self.from, - "to": self.to, - "subject": "StackArr Notification", - "body": event.summary(), - }); - self.client - .post(&self.smtp_url) - .json(&body) - .send() - .await? - .error_for_status()?; - Ok(()) - } - - async fn test(&self) -> Result<()> { - let test_event = NotificationEvent::HealthIssue { - source: "test".to_string(), - message: "This is a test notification from StackArr".to_string(), - }; - self.send(&test_event).await - } -} - -// ── Notification service ──────────────────────────────────────────────────── - -/// Dispatches events to all configured providers. -pub struct NotificationService { - providers: Vec>, -} - -impl NotificationService { - pub fn new() -> Self { - Self { - providers: Vec::new(), - } - } - - pub fn add_provider(&mut self, provider: Box) { - self.providers.push(provider); - } - - /// Send an event to all providers, logging errors but not failing. - pub async fn notify(&self, event: &NotificationEvent) { - for provider in &self.providers { - if let Err(e) = provider.send(event).await { - tracing::error!( - provider = provider.name(), - error = %e, - "notification send failed" - ); - } - } - } -} - -impl Default for NotificationService { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - - #[test] - fn test_event_summary_grab() { - let event = NotificationEvent::Grab { - title: "Breaking Bad S01E01".into(), - quality: "HDTV-720p".into(), - indexer: "NZBGeek".into(), - }; - let s = event.summary(); - assert!(s.contains("Breaking Bad S01E01")); - assert!(s.contains("HDTV-720p")); - } - - #[test] - fn test_event_summary_import() { - let event = NotificationEvent::Import { - title: "The Wire S03E05".into(), - quality: "Bluray-1080p".into(), - }; - let s = event.summary(); - assert!(s.contains("Imported")); - assert!(s.contains("The Wire S03E05")); - } - - #[test] - fn test_event_summary_upgrade() { - let event = NotificationEvent::Upgrade { - title: "Movie Title".into(), - old_quality: "HDTV-720p".into(), - new_quality: "Bluray-1080p".into(), - }; - let s = event.summary(); - assert!(s.contains("Upgraded")); - assert!(s.contains("Bluray-1080p")); - } - - #[test] - fn test_event_summary_health_issue() { - let event = NotificationEvent::HealthIssue { - source: "Indexer".into(), - message: "NZBGeek unavailable".into(), - }; - let s = event.summary(); - assert!(s.contains("Indexer")); - assert!(s.contains("NZBGeek unavailable")); - } - - #[test] - fn test_event_summary_download_failure() { - let event = NotificationEvent::DownloadFailure { - title: "Some.Release".into(), - message: "connection timeout".into(), - }; - let s = event.summary(); - assert!(s.contains("Failed")); - assert!(s.contains("connection timeout")); - } - - // ── NotificationService tests with manual mock ────────────────────── - - struct CountingProvider { - send_count: Arc, - } - - #[async_trait::async_trait] - impl NotificationProvider for CountingProvider { - fn name(&self) -> &str { "Counting" } - async fn send(&self, _event: &NotificationEvent) -> Result<()> { - self.send_count.fetch_add(1, Ordering::SeqCst); - Ok(()) - } - async fn test(&self) -> Result<()> { Ok(()) } - } - - struct FailingProvider; - - #[async_trait::async_trait] - impl NotificationProvider for FailingProvider { - fn name(&self) -> &str { "Failing" } - async fn send(&self, _event: &NotificationEvent) -> Result<()> { - anyhow::bail!("provider unavailable") - } - async fn test(&self) -> Result<()> { Ok(()) } - } - - #[tokio::test] - async fn test_service_fan_out() { - let count1 = Arc::new(AtomicUsize::new(0)); - let count2 = Arc::new(AtomicUsize::new(0)); - - let mut svc = NotificationService::new(); - svc.add_provider(Box::new(CountingProvider { send_count: count1.clone() })); - svc.add_provider(Box::new(CountingProvider { send_count: count2.clone() })); - - let event = NotificationEvent::Import { - title: "Test".into(), - quality: "720p".into(), - }; - svc.notify(&event).await; - - assert_eq!(count1.load(Ordering::SeqCst), 1); - assert_eq!(count2.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn test_service_error_doesnt_stop_others() { - let count = Arc::new(AtomicUsize::new(0)); - - let mut svc = NotificationService::new(); - svc.add_provider(Box::new(FailingProvider)); - svc.add_provider(Box::new(CountingProvider { send_count: count.clone() })); - - let event = NotificationEvent::HealthIssue { - source: "test".into(), - message: "msg".into(), - }; - svc.notify(&event).await; - - // Second provider should still have been called despite first one failing - assert_eq!(count.load(Ordering::SeqCst), 1); - } - - #[tokio::test] - async fn test_webhook_provider_sends_json() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path}; - - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let provider = WebhookProvider::new(mock_server.uri()); - let event = NotificationEvent::Grab { - title: "Test".into(), - quality: "1080p".into(), - indexer: "Idx".into(), - }; - provider.send(&event).await.expect("webhook should succeed"); - } - - #[tokio::test] - async fn test_telegram_provider_sends_message() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path, body_json}; - - let mock_server = MockServer::start().await; - - let event = NotificationEvent::Grab { - title: "Test".into(), - quality: "1080p".into(), - indexer: "Idx".into(), - }; - - Mock::given(method("POST")) - .and(path("/bot123:faketoken/sendMessage")) - .and(body_json(serde_json::json!({ - "chat_id": "456", - "text": event.summary(), - "parse_mode": "HTML", - }))) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - // Point the provider at the mock server instead of api.telegram.org - let provider = TelegramProvider { - client: reqwest::Client::new(), - bot_token: "123:faketoken".to_string(), - chat_id: "456".to_string(), - }; - // Override the URL by sending directly to the mock - let url = format!("{}/bot{}/sendMessage", mock_server.uri(), provider.bot_token); - let body = serde_json::json!({ - "chat_id": provider.chat_id, - "text": event.summary(), - "parse_mode": "HTML", - }); - provider - .client - .post(&url) - .json(&body) - .send() - .await - .expect("request should succeed") - .error_for_status() - .expect("should be 200"); - } - - #[tokio::test] - async fn test_slack_provider_sends_message() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path, body_json}; - - let mock_server = MockServer::start().await; - - let event = NotificationEvent::Import { - title: "Test Show S01E01".into(), - quality: "720p".into(), - }; - - Mock::given(method("POST")) - .and(path("/")) - .and(body_json(serde_json::json!({ - "text": event.summary(), - }))) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let provider = SlackProvider::new(mock_server.uri()); - provider.send(&event).await.expect("slack should succeed"); - } - - #[tokio::test] - async fn test_email_provider_sends_message() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path, body_json}; - - let mock_server = MockServer::start().await; - - let event = NotificationEvent::DownloadFailure { - title: "Some.Release".into(), - message: "connection timeout".into(), - }; - - Mock::given(method("POST")) - .and(path("/")) - .and(body_json(serde_json::json!({ - "from": "stackarr@example.com", - "to": "user@example.com", - "subject": "StackArr Notification", - "body": event.summary(), - }))) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let provider = EmailProvider::new( - mock_server.uri(), - "stackarr@example.com".to_string(), - "user@example.com".to_string(), - ); - provider.send(&event).await.expect("email should succeed"); - } - - #[tokio::test] - async fn test_telegram_provider_test_method() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path}; - - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/bot123:faketoken/sendMessage")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - // Build a provider that targets the mock server - let provider = TelegramProvider { - client: reqwest::Client::new(), - bot_token: "123:faketoken".to_string(), - chat_id: "456".to_string(), - }; - // Manually invoke the test flow against the mock - let url = format!("{}/bot{}/sendMessage", mock_server.uri(), provider.bot_token); - let test_event = NotificationEvent::HealthIssue { - source: "test".to_string(), - message: "This is a test notification from StackArr".to_string(), - }; - let body = serde_json::json!({ - "chat_id": provider.chat_id, - "text": test_event.summary(), - "parse_mode": "HTML", - }); - provider - .client - .post(&url) - .json(&body) - .send() - .await - .expect("request should succeed") - .error_for_status() - .expect("should be 200"); - } - - #[tokio::test] - async fn test_slack_provider_test_method() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path}; - - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let provider = SlackProvider::new(mock_server.uri()); - provider.test().await.expect("slack test should succeed"); - } - - #[tokio::test] - async fn test_email_provider_test_method() { - use wiremock::{MockServer, Mock, ResponseTemplate}; - use wiremock::matchers::{method, path}; - - let mock_server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let provider = EmailProvider::new( - mock_server.uri(), - "stackarr@example.com".to_string(), - "user@example.com".to_string(), - ); - provider.test().await.expect("email test should succeed"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/Cargo.toml deleted file mode 100644 index 528e7b66..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "stackarr-parser" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -regex = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -once_cell = "1" diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/episode.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/episode.rs deleted file mode 100644 index d9efebb4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/episode.rs +++ /dev/null @@ -1,254 +0,0 @@ -use chrono::NaiveDate; -use once_cell::sync::Lazy; -use regex::Regex; -use serde::{Deserialize, Serialize}; - -/// Parsed episode information from a release name. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct EpisodeInfo { - pub season_number: Option, - pub episode_numbers: Vec, - pub absolute_episode_numbers: Vec, - pub air_date: Option, - pub is_full_season: bool, - pub is_multi_season: bool, - pub is_special: bool, -} - -impl Default for EpisodeInfo { - fn default() -> Self { - Self { - season_number: None, - episode_numbers: Vec::new(), - absolute_episode_numbers: Vec::new(), - air_date: None, - is_full_season: false, - is_multi_season: false, - is_special: false, - } - } -} - -// ── Regex patterns ────────────────────────────────────────────────────────── - -// S01E01, S01E01E02, S01E01-E03 -static RE_STANDARD: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\bS(\d{1,4})E(\d{1,4})(?:[-E]+E?(\d{1,4}))*\b").unwrap() -}); - -// Multi-episode range: S01E01-E05 or S01E01-05 -static RE_MULTI_EPISODE: Lazy = Lazy::new(|| { - Regex::new(r"(?i)\bS(\d{1,4})E(\d{1,4})-E?(\d{1,4})\b").unwrap() -}); - -// Full season: S01 — we match S followed by digits, then check context in code -// (rust regex crate doesn't support lookahead) -static RE_FULL_SEASON: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bS(\d{1,4})\b").unwrap()); - -// Multi-season: S01-S03 -static RE_MULTI_SEASON: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bS(\d{1,4})-S(\d{1,4})\b").unwrap()); - -// Daily format: 2024.01.15 or 2024-01-15 -static RE_DAILY: Lazy = Lazy::new(|| { - Regex::new(r"\b((?:19|20)\d{2})[.\-](0[1-9]|1[0-2])[.\-](0[1-9]|[12]\d|3[01])\b").unwrap() -}); - -// Absolute episode number: standalone number like " - 123 " or ".123." -// Must not be a year (19xx/20xx) and should be preceded by known delimiters -static RE_ABSOLUTE: Lazy = - Lazy::new(|| Regex::new(r"(?:^|[\s.\-_])(\d{2,4})(?:[\s.\-_v]|$)").unwrap()); - -// Specials: S00 or Season 0 -static RE_SPECIAL: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b(?:S00|[Ss]eason[\s._]*0|[Ss]pecial)\b").unwrap()); - -// Used to check if an S-match is followed by E (making it NOT a full season) -static RE_HAS_EPISODE: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bS\d{1,4}E\d").unwrap()); - -/// Parse episode information from a release name. -pub fn parse_episodes(name: &str) -> EpisodeInfo { - let mut info = EpisodeInfo::default(); - - // Check for specials - if RE_SPECIAL.is_match(name) { - info.is_special = true; - } - - // Try multi-season first: S01-S03 - if let Some(caps) = RE_MULTI_SEASON.captures(name) { - let s1: i32 = caps[1].parse().unwrap_or(0); - let _s2: i32 = caps[2].parse().unwrap_or(0); - info.season_number = Some(s1); - info.is_multi_season = true; - info.is_full_season = true; - return info; - } - - // Try multi-episode range: S01E01-E05 or S01E01-05 - if let Some(caps) = RE_MULTI_EPISODE.captures(name) { - let season: i32 = caps[1].parse().unwrap_or(0); - let ep_start: i32 = caps[2].parse().unwrap_or(0); - let ep_end: i32 = caps[3].parse().unwrap_or(0); - - info.season_number = Some(season); - if ep_end >= ep_start { - info.episode_numbers = (ep_start..=ep_end).collect(); - } else { - info.episode_numbers = vec![ep_start, ep_end]; - } - return info; - } - - // Try standard: S01E01, S01E01E02 - if let Some(caps) = RE_STANDARD.captures(name) { - let season: i32 = caps[1].parse().unwrap_or(0); - info.season_number = Some(season); - - // Collect all episode numbers from the match - let full_match = caps.get(0).unwrap().as_str(); - let ep_re = Regex::new(r"(?i)E(\d{1,4})").unwrap(); - for ep_cap in ep_re.captures_iter(full_match) { - if let Ok(ep) = ep_cap[1].parse::() { - info.episode_numbers.push(ep); - } - } - return info; - } - - // Try full season: S01 (no episode) - // Only matches if there is NO SxxExx pattern in the name - if !RE_HAS_EPISODE.is_match(name) { - if let Some(caps) = RE_FULL_SEASON.captures(name) { - let season: i32 = caps[1].parse().unwrap_or(0); - info.season_number = Some(season); - info.is_full_season = true; - return info; - } - } - - // Try daily: 2024.01.15 - if let Some(caps) = RE_DAILY.captures(name) { - let year: i32 = caps[1].parse().unwrap_or(0); - let month: u32 = caps[2].parse().unwrap_or(0); - let day: u32 = caps[3].parse().unwrap_or(0); - - if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { - info.air_date = Some(date); - } - return info; - } - - // Try absolute episode number (only if nothing else matched) - // Look for numbers that are not years - for cap in RE_ABSOLUTE.captures_iter(name) { - if let Ok(num) = cap[1].parse::() { - // Exclude likely years - if !(1900..=2099).contains(&num) && num > 0 { - info.absolute_episode_numbers.push(num); - } - } - } - - info -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_standard_single_episode() { - let info = parse_episodes("Show.Name.S01E01.720p.HDTV.x264-GROUP"); - assert_eq!(info.season_number, Some(1)); - assert_eq!(info.episode_numbers, vec![1]); - assert!(!info.is_full_season); - } - - #[test] - fn test_standard_multi_episode() { - let info = parse_episodes("Show.Name.S01E01E02.720p.HDTV.x264-GROUP"); - assert_eq!(info.season_number, Some(1)); - assert_eq!(info.episode_numbers, vec![1, 2]); - } - - #[test] - fn test_episode_range() { - let info = parse_episodes("Show.Name.S01E01-E03.720p.HDTV.x264-GROUP"); - assert_eq!(info.season_number, Some(1)); - assert_eq!(info.episode_numbers, vec![1, 2, 3]); - } - - #[test] - fn test_episode_range_no_e_prefix() { - let info = parse_episodes("Show.Name.S01E01-03.720p.HDTV.x264-GROUP"); - assert_eq!(info.season_number, Some(1)); - assert_eq!(info.episode_numbers, vec![1, 2, 3]); - } - - #[test] - fn test_full_season() { - let info = parse_episodes("Show.Name.S01.720p.BluRay.x264-GROUP"); - assert_eq!(info.season_number, Some(1)); - assert!(info.is_full_season); - assert!(info.episode_numbers.is_empty()); - } - - #[test] - fn test_multi_season() { - let info = parse_episodes("Show.Name.S01-S03.720p.BluRay.x264-GROUP"); - assert_eq!(info.season_number, Some(1)); - assert!(info.is_multi_season); - assert!(info.is_full_season); - } - - #[test] - fn test_daily_show() { - let info = parse_episodes("Show.Name.2024.01.15.720p.HDTV.x264-GROUP"); - assert_eq!( - info.air_date, - Some(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()) - ); - assert!(info.episode_numbers.is_empty()); - } - - #[test] - fn test_daily_show_dashes() { - let info = parse_episodes("Show.Name.2024-03-22.720p.HDTV.x264-GROUP"); - assert_eq!( - info.air_date, - Some(NaiveDate::from_ymd_opt(2024, 3, 22).unwrap()) - ); - } - - #[test] - fn test_special() { - let info = parse_episodes("Show.Name.S00E01.Special.720p.HDTV.x264-GROUP"); - assert!(info.is_special); - assert_eq!(info.season_number, Some(0)); - } - - #[test] - fn test_absolute_episode() { - let info = parse_episodes("Anime.Name.123.720p.WEB-DL.x264-GROUP"); - assert!(info.absolute_episode_numbers.contains(&123)); - } - - #[test] - fn test_high_season_episode() { - let info = parse_episodes("Show.Name.S12E24.720p.HDTV.x264-GROUP"); - assert_eq!(info.season_number, Some(12)); - assert_eq!(info.episode_numbers, vec![24]); - } - - #[test] - fn test_no_match() { - let info = parse_episodes("random.file.name"); - assert_eq!(info.season_number, None); - assert!(info.episode_numbers.is_empty()); - assert!(info.absolute_episode_numbers.is_empty()); - assert!(info.air_date.is_none()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/language.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/language.rs deleted file mode 100644 index 13988ecd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/language.rs +++ /dev/null @@ -1,183 +0,0 @@ -use once_cell::sync::Lazy; -use regex::Regex; -use serde::{Deserialize, Serialize}; - -/// Languages that can be detected in release names. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Language { - Unknown, - English, - French, - Spanish, - German, - Italian, - Portuguese, - Japanese, - Chinese, - Korean, - Russian, - Polish, - Dutch, - Swedish, - Norwegian, - Danish, - Finnish, - Turkish, - Arabic, - Hindi, - Czech, - Hungarian, - Romanian, - Greek, - Hebrew, - Thai, - Vietnamese, - Indonesian, - Multi, -} - -struct CompiledLanguagePattern { - regex: Regex, - language: Language, -} - -static LANGUAGE_PATTERNS: Lazy> = Lazy::new(|| { - let patterns: &[(&str, Language)] = &[ - (r"(?i)\b(?:MULTi)\b", Language::Multi), - (r"(?i)\b(?:ENGLISH|ENG)\b", Language::English), - ( - r"(?i)\b(?:FRENCH|VOSTFR|VFF|VFQ|TRUEFRENCH|FRE)\b", - Language::French, - ), - ( - r"(?i)\b(?:SPANISH|ESPANOL|SPA|CASTELLANO|LATINO)\b", - Language::Spanish, - ), - ( - r"(?i)\b(?:GERMAN|GER|DEUTSCH|DTS\-?GER)\b", - Language::German, - ), - (r"(?i)\b(?:ITALIAN|ITA)\b", Language::Italian), - (r"(?i)\b(?:PORTUGUESE|POR|PTBR)\b", Language::Portuguese), - (r"(?i)\b(?:JAPANESE|JPN|JAP)\b", Language::Japanese), - ( - r"(?i)\b(?:CHINESE|CHI|CHS|CHT|MANDARIN|CANTONESE)\b", - Language::Chinese, - ), - (r"(?i)\b(?:KOREAN|KOR)\b", Language::Korean), - (r"(?i)\b(?:RUSSIAN|RUS)\b", Language::Russian), - (r"(?i)\b(?:POLISH|POL|PL)\b", Language::Polish), - (r"(?i)\b(?:DUTCH|NLD|DUT|FLEMISH)\b", Language::Dutch), - (r"(?i)\b(?:SWEDISH|SWE)\b", Language::Swedish), - (r"(?i)\b(?:NORWEGIAN|NOR)\b", Language::Norwegian), - (r"(?i)\b(?:DANISH|DAN)\b", Language::Danish), - (r"(?i)\b(?:FINNISH|FIN)\b", Language::Finnish), - (r"(?i)\b(?:TURKISH|TUR)\b", Language::Turkish), - (r"(?i)\b(?:ARABIC|ARA)\b", Language::Arabic), - (r"(?i)\b(?:HINDI|HIN)\b", Language::Hindi), - (r"(?i)\b(?:CZECH|CZE|CES)\b", Language::Czech), - (r"(?i)\b(?:HUNGARIAN|HUN)\b", Language::Hungarian), - (r"(?i)\b(?:ROMANIAN|ROM|RON)\b", Language::Romanian), - (r"(?i)\b(?:GREEK|GRE|ELL)\b", Language::Greek), - (r"(?i)\b(?:HEBREW|HEB)\b", Language::Hebrew), - (r"(?i)\b(?:THAI|THA)\b", Language::Thai), - (r"(?i)\b(?:VIETNAMESE|VIE)\b", Language::Vietnamese), - (r"(?i)\b(?:INDONESIAN|IND)\b", Language::Indonesian), - ]; - - patterns - .iter() - .map(|(pat, lang)| CompiledLanguagePattern { - regex: Regex::new(pat).unwrap(), - language: *lang, - }) - .collect() -}); - -/// Detect languages mentioned in a release name. -/// -/// Returns a vector of detected [`Language`] values. Returns -/// `vec![Language::Unknown]` if no language tag is found. -pub fn parse_languages(name: &str) -> Vec { - let mut languages = Vec::new(); - - for pattern in LANGUAGE_PATTERNS.iter() { - if pattern.regex.is_match(name) { - languages.push(pattern.language); - } - } - - if languages.is_empty() { - languages.push(Language::Unknown); - } - - languages -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_no_language() { - let langs = parse_languages("Show.Name.S01E01.720p.HDTV.x264-GROUP"); - assert_eq!(langs, vec![Language::Unknown]); - } - - #[test] - fn test_english() { - let langs = parse_languages("Show.Name.S01E01.ENGLISH.720p.HDTV.x264-GROUP"); - assert!(langs.contains(&Language::English)); - } - - #[test] - fn test_french() { - let langs = parse_languages("Show.Name.S01E01.FRENCH.720p.HDTV.x264-GROUP"); - assert!(langs.contains(&Language::French)); - } - - #[test] - fn test_truefrench() { - let langs = parse_languages("Show.Name.S01E01.TRUEFRENCH.1080p.BluRay-GROUP"); - assert!(langs.contains(&Language::French)); - } - - #[test] - fn test_multi() { - let langs = parse_languages("Show.Name.S01E01.MULTi.1080p.BluRay.x264-GROUP"); - assert!(langs.contains(&Language::Multi)); - } - - #[test] - fn test_german() { - let langs = parse_languages("Show.Name.S01E01.GERMAN.720p.HDTV.x264-GROUP"); - assert!(langs.contains(&Language::German)); - } - - #[test] - fn test_japanese() { - let langs = parse_languages("Anime.Name.S01E01.JPN.1080p.WEB-DL-GROUP"); - assert!(langs.contains(&Language::Japanese)); - } - - #[test] - fn test_multiple_languages() { - let langs = - parse_languages("Show.Name.S01E01.MULTi.FRENCH.ENGLISH.1080p.BluRay-GROUP"); - assert!(langs.contains(&Language::Multi)); - assert!(langs.contains(&Language::French)); - assert!(langs.contains(&Language::English)); - } - - #[test] - fn test_spanish_latino() { - let langs = parse_languages("Movie.Name.2024.LATINO.1080p.WEB-DL-GROUP"); - assert!(langs.contains(&Language::Spanish)); - } - - #[test] - fn test_korean() { - let langs = parse_languages("KDrama.Name.S01E01.KOR.1080p.WEB-DL-GROUP"); - assert!(langs.contains(&Language::Korean)); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/lib.rs deleted file mode 100644 index 7550d102..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod episode; -pub mod language; -pub mod quality; -pub mod release; -pub mod title; - -pub use language::{Language, parse_languages}; -pub use quality::{Quality, QualityModel, Revision}; -pub use release::{parse_release, ParsedRelease}; -pub use title::clean_title; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/quality.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/quality.rs deleted file mode 100644 index 1b1e77c6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/quality.rs +++ /dev/null @@ -1,301 +0,0 @@ -use once_cell::sync::Lazy; -use regex::Regex; -use serde::{Deserialize, Serialize}; - -/// Video quality level detected from a release name. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum Quality { - Unknown, - SDTV, - DVD, - WEBDL480p, - HDTV720p, - HDTV1080p, - Raw, - WEBDL720p, - Bluray720p, - WEBDL1080p, - Bluray1080p, - HDTV2160p, - WEBDL2160p, - Bluray2160p, - DVDRip, - WEBRip480p, - WEBRip720p, - WEBRip1080p, - WEBRip2160p, - Remux1080p, - Remux2160p, -} - -/// Revision information for a release (PROPER, REPACK, etc.). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Revision { - pub version: i32, - pub real: i32, -} - -impl Default for Revision { - fn default() -> Self { - Self { - version: 1, - real: 0, - } - } -} - -/// Complete quality model combining the quality level and revision. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct QualityModel { - pub quality: Quality, - pub revision: Revision, -} - -// ── Regex patterns ────────────────────────────────────────────────────────── - -static RE_RESOLUTION: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b(2160|1080|720|480)[pi]\b").unwrap()); - -static RE_SOURCE_REMUX: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b[Rr][Ee][Mm][Uu][Xx]\b").unwrap()); - -static RE_SOURCE_BLURAY: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b(BluRay|BDRip|BRRip|BD[Rr]ip)\b").unwrap()); - -static RE_SOURCE_WEBDL: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bWEB[.\-_ ]?DL\b").unwrap()); - -static RE_SOURCE_WEBRIP: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bWEB[Rr]ip\b").unwrap()); - -static RE_SOURCE_HDTV: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b(HDTV|PDTV|DSR)\b").unwrap()); - -static RE_SOURCE_DVDRIP: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bDVD[Rr]ip\b").unwrap()); - -static RE_SOURCE_DVD: Lazy = - Lazy::new(|| Regex::new(r"(?i)\bDVD(?:R|9|5)?\b").unwrap()); - -static RE_SOURCE_RAW: Lazy = - Lazy::new(|| Regex::new(r"(?i)\b[Rr][Aa][Ww]\b").unwrap()); - -static RE_PROPER: Lazy = Lazy::new(|| Regex::new(r"(?i)\bPROPER\b").unwrap()); - -static RE_REPACK: Lazy = Lazy::new(|| Regex::new(r"(?i)\bREPACK\b").unwrap()); - -static RE_REAL: Lazy = Lazy::new(|| Regex::new(r"(?i)\bREAL\b").unwrap()); - -/// Parse a release name into a [`QualityModel`]. -pub fn parse_quality(name: &str) -> QualityModel { - let revision = parse_revision(name); - - // Determine resolution - let resolution: Option = RE_RESOLUTION - .captures(name) - .and_then(|c| c.get(1)) - .and_then(|m| m.as_str().parse().ok()); - - // Determine source - let is_remux = RE_SOURCE_REMUX.is_match(name); - let is_bluray = RE_SOURCE_BLURAY.is_match(name); - let is_webdl = RE_SOURCE_WEBDL.is_match(name); - let is_webrip = RE_SOURCE_WEBRIP.is_match(name); - let is_hdtv = RE_SOURCE_HDTV.is_match(name); - let is_dvdrip = RE_SOURCE_DVDRIP.is_match(name); - let is_dvd = RE_SOURCE_DVD.is_match(name) && !is_dvdrip; - let is_raw = RE_SOURCE_RAW.is_match(name); - - let quality = match (resolution, is_remux, is_bluray, is_webdl, is_webrip, is_hdtv, is_dvdrip, is_dvd, is_raw) { - // Raw - (_, _, _, _, _, _, _, _, true) => Quality::Raw, - - // Remux - (Some(2160), true, _, _, _, _, _, _, _) | (None, true, _, _, _, _, _, _, _) if resolution.unwrap_or(2160) >= 2160 => Quality::Remux2160p, - (Some(1080), true, _, _, _, _, _, _, _) => Quality::Remux1080p, - (_, true, _, _, _, _, _, _, _) => Quality::Remux1080p, - - // BluRay (non-remux) - (Some(2160), _, true, _, _, _, _, _, _) => Quality::Bluray2160p, - (Some(1080), _, true, _, _, _, _, _, _) => Quality::Bluray1080p, - (Some(720), _, true, _, _, _, _, _, _) => Quality::Bluray720p, - (_, _, true, _, _, _, _, _, _) => Quality::Bluray1080p, - - // WEB-DL - (Some(2160), _, _, true, _, _, _, _, _) => Quality::WEBDL2160p, - (Some(1080), _, _, true, _, _, _, _, _) => Quality::WEBDL1080p, - (Some(720), _, _, true, _, _, _, _, _) => Quality::WEBDL720p, - (Some(480), _, _, true, _, _, _, _, _) => Quality::WEBDL480p, - (_, _, _, true, _, _, _, _, _) => Quality::WEBDL720p, - - // WEBRip - (Some(2160), _, _, _, true, _, _, _, _) => Quality::WEBRip2160p, - (Some(1080), _, _, _, true, _, _, _, _) => Quality::WEBRip1080p, - (Some(720), _, _, _, true, _, _, _, _) => Quality::WEBRip720p, - (Some(480), _, _, _, true, _, _, _, _) => Quality::WEBRip480p, - (_, _, _, _, true, _, _, _, _) => Quality::WEBRip720p, - - // HDTV - (Some(2160), _, _, _, _, true, _, _, _) => Quality::HDTV2160p, - (Some(1080), _, _, _, _, true, _, _, _) => Quality::HDTV1080p, - (Some(720), _, _, _, _, true, _, _, _) => Quality::HDTV720p, - (_, _, _, _, _, true, _, _, _) => Quality::SDTV, - - // DVDRip - (_, _, _, _, _, _, true, _, _) => Quality::DVDRip, - - // DVD - (_, _, _, _, _, _, _, true, _) => Quality::DVD, - - // Resolution only (no source) - (Some(2160), _, _, _, _, _, _, _, _) => Quality::HDTV2160p, - (Some(1080), _, _, _, _, _, _, _, _) => Quality::HDTV1080p, - (Some(720), _, _, _, _, _, _, _, _) => Quality::HDTV720p, - (Some(480), _, _, _, _, _, _, _, _) => Quality::SDTV, - - _ => Quality::Unknown, - }; - - QualityModel { quality, revision } -} - -fn parse_revision(name: &str) -> Revision { - let mut version = 1i32; - let mut real = 0i32; - - if RE_PROPER.is_match(name) { - version = 2; - } - if RE_REPACK.is_match(name) { - version = 2; - } - if RE_REAL.is_match(name) { - real = 1; - } - - Revision { version, real } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_hdtv_720p() { - let q = parse_quality("Show.Name.S01E01.720p.HDTV.x264-GROUP"); - assert_eq!(q.quality, Quality::HDTV720p); - assert_eq!(q.revision.version, 1); - } - - #[test] - fn test_hdtv_1080p() { - let q = parse_quality("Show.Name.S01E01.1080p.HDTV.x264-GROUP"); - assert_eq!(q.quality, Quality::HDTV1080p); - } - - #[test] - fn test_webdl_1080p() { - let q = parse_quality("Show.Name.S01E01.1080p.WEB-DL.DD5.1.H264-GROUP"); - assert_eq!(q.quality, Quality::WEBDL1080p); - } - - #[test] - fn test_webdl_2160p() { - let q = parse_quality("Show.Name.S01E01.2160p.WEB.DL.DDP5.1.H265-GROUP"); - assert_eq!(q.quality, Quality::WEBDL2160p); - } - - #[test] - fn test_webrip_1080p() { - let q = parse_quality("Show.Name.S01E01.1080p.WEBRip.x264-GROUP"); - assert_eq!(q.quality, Quality::WEBRip1080p); - } - - #[test] - fn test_bluray_1080p() { - let q = parse_quality("Show.Name.S01E01.1080p.BluRay.x264-GROUP"); - assert_eq!(q.quality, Quality::Bluray1080p); - } - - #[test] - fn test_bluray_2160p() { - let q = parse_quality("Movie.Name.2024.2160p.BluRay.REMUX.HEVC.DTS-HD-GROUP"); - assert_eq!(q.quality, Quality::Remux2160p); - } - - #[test] - fn test_remux_1080p() { - let q = parse_quality("Movie.Name.2024.1080p.Remux.AVC.DTS-HD-GROUP"); - assert_eq!(q.quality, Quality::Remux1080p); - } - - #[test] - fn test_dvdrip() { - let q = parse_quality("Show.Name.S01E01.DVDRip.x264-GROUP"); - assert_eq!(q.quality, Quality::DVDRip); - } - - #[test] - fn test_sdtv_hdtv_no_resolution() { - let q = parse_quality("Show.Name.S01E01.HDTV.x264-GROUP"); - assert_eq!(q.quality, Quality::SDTV); - } - - #[test] - fn test_proper() { - let q = parse_quality("Show.Name.S01E01.720p.HDTV.PROPER.x264-GROUP"); - assert_eq!(q.quality, Quality::HDTV720p); - assert_eq!(q.revision.version, 2); - } - - #[test] - fn test_repack() { - let q = parse_quality("Show.Name.S01E01.720p.HDTV.REPACK.x264-GROUP"); - assert_eq!(q.quality, Quality::HDTV720p); - assert_eq!(q.revision.version, 2); - } - - #[test] - fn test_real() { - let q = parse_quality("Show.Name.S01E01.720p.HDTV.REAL.PROPER.x264-GROUP"); - assert_eq!(q.quality, Quality::HDTV720p); - assert_eq!(q.revision.version, 2); - assert_eq!(q.revision.real, 1); - } - - #[test] - fn test_unknown() { - let q = parse_quality("something_random"); - assert_eq!(q.quality, Quality::Unknown); - } - - #[test] - fn test_bdrip() { - let q = parse_quality("Movie.2020.1080p.BDRip.x264-GROUP"); - assert_eq!(q.quality, Quality::Bluray1080p); - } - - #[test] - fn test_brrip() { - let q = parse_quality("Movie.2020.720p.BRRip.x264-GROUP"); - assert_eq!(q.quality, Quality::Bluray720p); - } - - #[test] - fn test_webrip_480p() { - let q = parse_quality("Show.S01E01.480p.WEBRip.x264-GROUP"); - assert_eq!(q.quality, Quality::WEBRip480p); - } - - #[test] - fn test_webdl_480p() { - let q = parse_quality("Show.S01E01.480p.WEB-DL.x264-GROUP"); - assert_eq!(q.quality, Quality::WEBDL480p); - } - - #[test] - fn test_raw() { - let q = parse_quality("Show.Name.S01E01.Raw.TS"); - assert_eq!(q.quality, Quality::Raw); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/release.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/release.rs deleted file mode 100644 index c991b434..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/release.rs +++ /dev/null @@ -1,257 +0,0 @@ -use once_cell::sync::Lazy; -use regex::Regex; -use serde::{Deserialize, Serialize}; - -use crate::episode::{parse_episodes, EpisodeInfo}; -use crate::language::{parse_languages, Language}; -use crate::quality::{parse_quality, QualityModel}; -use crate::title::parse_title; - -/// A fully parsed release name with all extracted metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ParsedRelease { - pub title: String, - pub quality: QualityModel, - pub episode_info: EpisodeInfo, - pub languages: Vec, - pub release_group: Option, - pub release_hash: Option, - pub year: Option, - pub edition: Option, - pub imdb_id: Option, -} - -// ── Regex patterns ────────────────────────────────────────────────────────── - -// Release group: last hyphen-separated segment (excluding file extension) -static RE_RELEASE_GROUP: Lazy = - Lazy::new(|| Regex::new(r"-([A-Za-z0-9]+)(?:\.[a-zA-Z]{2,4})?$").unwrap()); - -// Release hash: 8-char hex in square brackets -static RE_RELEASE_HASH: Lazy = - Lazy::new(|| Regex::new(r"\[([0-9a-fA-F]{8})\]").unwrap()); - -// Year: 4-digit year (1900-2099) -static RE_YEAR: Lazy = - Lazy::new(|| Regex::new(r"(?:^|[\s.\-_\(])((?:19|20)\d{2})(?:[\s.\-_\)]|$)").unwrap()); - -// Edition: Director's Cut, Extended, Unrated, etc. -static RE_EDITION: Lazy = Lazy::new(|| { - Regex::new( - r"(?i)\b(Director'?s?.?Cut|Extended.?(?:Edition|Cut)?|Unrated|Theatrical|Ultimate.?(?:Edition|Cut)|Criterion|IMAX|Remastered|Anniversary.?Edition|Collector'?s?.?Edition|Special.?Edition|Deluxe.?Edition)\b", - ) - .unwrap() -}); - -// IMDB ID: tt followed by 7-8 digits -static RE_IMDB: Lazy = Lazy::new(|| Regex::new(r"\b(tt\d{7,8})\b").unwrap()); - -/// Parse a release name into a fully structured [`ParsedRelease`]. -pub fn parse_release(name: &str) -> ParsedRelease { - let title = parse_title(name); - let quality = parse_quality(name); - let episode_info = parse_episodes(name); - let languages = parse_languages(name); - - let release_group = RE_RELEASE_GROUP - .captures(name) - .map(|c| c[1].to_string()); - - let release_hash = RE_RELEASE_HASH - .captures(name) - .map(|c| c[1].to_string()); - - let year = RE_YEAR - .captures(name) - .and_then(|c| c[1].parse::().ok()); - - let edition = RE_EDITION.captures(name).map(|c| c[1].to_string()); - - let imdb_id = RE_IMDB.captures(name).map(|c| c[1].to_string()); - - ParsedRelease { - title, - quality, - episode_info, - languages, - release_group, - release_hash, - year, - edition, - imdb_id, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::quality::Quality; - - #[test] - fn test_standard_tv_release() { - let r = parse_release("The.Office.S01E01.720p.HDTV.x264-LOL"); - assert_eq!(r.title, "The Office"); - assert_eq!(r.quality.quality, Quality::HDTV720p); - assert_eq!(r.episode_info.season_number, Some(1)); - assert_eq!(r.episode_info.episode_numbers, vec![1]); - assert_eq!(r.release_group.as_deref(), Some("LOL")); - } - - #[test] - fn test_movie_with_year() { - let r = parse_release("Inception.2010.1080p.BluRay.x264-GROUP"); - assert_eq!(r.title, "Inception"); - assert_eq!(r.year, Some(2010)); - assert_eq!(r.quality.quality, Quality::Bluray1080p); - assert_eq!(r.release_group.as_deref(), Some("GROUP")); - } - - #[test] - fn test_4k_webrip() { - let r = parse_release("Movie.Name.2024.2160p.WEBRip.DDP5.1.x265-GROUP"); - assert_eq!(r.title, "Movie Name"); - assert_eq!(r.quality.quality, Quality::WEBRip2160p); - assert_eq!(r.year, Some(2024)); - } - - #[test] - fn test_multi_episode() { - let r = parse_release("Show.S02E01E02.1080p.WEB-DL.DD5.1-GROUP"); - assert_eq!(r.episode_info.season_number, Some(2)); - assert_eq!(r.episode_info.episode_numbers, vec![1, 2]); - assert_eq!(r.quality.quality, Quality::WEBDL1080p); - } - - #[test] - fn test_daily_show() { - let r = parse_release("Late.Night.Show.2024.01.15.720p.HDTV.x264-GROUP"); - assert!(r.episode_info.air_date.is_some()); - let date = r.episode_info.air_date.unwrap(); - assert_eq!(date.to_string(), "2024-01-15"); - } - - #[test] - fn test_release_hash() { - let r = parse_release("[SubGroup] Anime Name - 01 [1080p] [ABCD1234].mkv"); - assert_eq!(r.release_hash.as_deref(), Some("ABCD1234")); - } - - #[test] - fn test_edition_directors_cut() { - let r = parse_release("Movie.Name.2024.Directors.Cut.1080p.BluRay.x264-GROUP"); - assert!(r.edition.is_some()); - assert!(r.edition.as_ref().unwrap().contains("Directors")); - } - - #[test] - fn test_edition_extended() { - let r = parse_release("Movie.Name.2024.Extended.Edition.1080p.BluRay.x264-GROUP"); - assert!(r.edition.is_some()); - } - - #[test] - fn test_edition_unrated() { - let r = parse_release("Movie.Name.2024.Unrated.1080p.BluRay.x264-GROUP"); - assert_eq!(r.edition.as_deref(), Some("Unrated")); - } - - #[test] - fn test_edition_remastered() { - let r = parse_release("Movie.Name.1994.Remastered.1080p.BluRay.x264-GROUP"); - assert_eq!(r.edition.as_deref(), Some("Remastered")); - } - - #[test] - fn test_imdb_id() { - let r = parse_release("Movie.Name.2024.1080p.BluRay.x264-GROUP.tt1234567"); - assert_eq!(r.imdb_id.as_deref(), Some("tt1234567")); - } - - #[test] - fn test_imdb_id_8_digits() { - let r = parse_release("Movie.Name.2024.1080p.BluRay.x264-GROUP.tt12345678"); - assert_eq!(r.imdb_id.as_deref(), Some("tt12345678")); - } - - #[test] - fn test_proper_release() { - let r = parse_release("Show.S01E01.720p.HDTV.PROPER.x264-GROUP"); - assert_eq!(r.quality.revision.version, 2); - } - - #[test] - fn test_repack_release() { - let r = parse_release("Show.S01E01.720p.HDTV.REPACK.x264-GROUP"); - assert_eq!(r.quality.revision.version, 2); - } - - #[test] - fn test_full_season() { - let r = parse_release("Show.Name.S03.1080p.BluRay.x264-GROUP"); - assert!(r.episode_info.is_full_season); - assert_eq!(r.episode_info.season_number, Some(3)); - } - - #[test] - fn test_language_detection() { - let r = parse_release("Movie.Name.2024.FRENCH.1080p.BluRay.x264-GROUP"); - assert!(r.languages.contains(&Language::French)); - } - - #[test] - fn test_multi_language() { - let r = parse_release("Movie.Name.2024.MULTi.1080p.BluRay.x264-GROUP"); - assert!(r.languages.contains(&Language::Multi)); - } - - #[test] - fn test_complex_release() { - let r = parse_release( - "The.Lord.of.the.Rings.The.Return.of.the.King.2003.Extended.Edition.2160p.BluRay.REMUX.HEVC.DTS-HD.MA.6.1-GROUP", - ); - assert_eq!(r.title, "The Lord of the Rings The Return of the King"); - assert_eq!(r.year, Some(2003)); - assert!(r.edition.is_some()); - assert_eq!(r.quality.quality, Quality::Remux2160p); - assert_eq!(r.release_group.as_deref(), Some("GROUP")); - } - - #[test] - fn test_webdl_with_dots() { - let r = parse_release("Show.Name.S01E01.1080p.WEB.DL.DDP5.1.H.265-GROUP"); - assert_eq!(r.quality.quality, Quality::WEBDL1080p); - } - - #[test] - fn test_no_group() { - let r = parse_release("Show.Name.S01E01.720p.HDTV"); - // No hyphen means no group extracted - assert!(r.release_group.is_none()); - } - - #[test] - fn test_special_episode() { - let r = parse_release("Show.Name.S00E01.Christmas.Special.720p.HDTV-GROUP"); - assert!(r.episode_info.is_special); - } - - #[test] - fn test_anime_absolute() { - let r = parse_release("Anime.Name.142.720p.WEB-DL.x264-SubGroup"); - assert!(r.episode_info.absolute_episode_numbers.contains(&142)); - } - - #[test] - fn test_dvdrip_movie() { - let r = parse_release("Movie.Name.2005.DVDRip.x264-GROUP"); - assert_eq!(r.quality.quality, Quality::DVDRip); - assert_eq!(r.year, Some(2005)); - } - - #[test] - fn test_real_proper() { - let r = parse_release("Show.S01E01.720p.HDTV.REAL.PROPER.x264-GROUP"); - assert_eq!(r.quality.revision.version, 2); - assert_eq!(r.quality.revision.real, 1); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/title.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/title.rs deleted file mode 100644 index 9d8a7cb3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-parser/src/title.rs +++ /dev/null @@ -1,171 +0,0 @@ -use once_cell::sync::Lazy; -use regex::Regex; - -// Pattern that marks the end of a title — episode info, quality, year, etc. -static RE_TITLE_STOP: Lazy = Lazy::new(|| { - Regex::new( - r"(?i)(?:[\.\s_\-](?:S\d{1,4}(?:E\d{1,4}|[\-]S\d)|(?:19|20)\d{2}[\.\s_\-](?:0[1-9]|1[0-2])[\.\s_\-](?:0[1-9]|[12]\d|3[01])|(?:19|20)\d{2}|480[pi]|720[pi]|1080[pi]|2160[pi]|HDTV|WEB[\.\-_ ]?DL|WEBRip|BluRay|BDRip|BRRip|DVDRip|DVD|PDTV|Remux|PROPER|REPACK|REAL|x264|x265|h\.?264|h\.?265|HEVC|XviD|AAC|DTS|DD5|DDP|AC3|FLAC|MP3|TrueHD|Atmos))" - ).unwrap() -}); - -static RE_DOTS_UNDERSCORES: Lazy = Lazy::new(|| Regex::new(r"[._]").unwrap()); - -static RE_NON_ALNUM: Lazy = Lazy::new(|| Regex::new(r"[^a-z0-9\s]").unwrap()); - -static RE_MULTI_SPACE: Lazy = Lazy::new(|| Regex::new(r"\s{2,}").unwrap()); - -/// Extract the series or movie title from a release name. -/// -/// Returns everything before episode/quality/year markers, with dots and -/// underscores replaced by spaces and the result trimmed. -pub fn parse_title(name: &str) -> String { - // Find where the title ends (first episode/quality marker) - let title_part = if let Some(m) = RE_TITLE_STOP.find(name) { - &name[..m.start()] - } else { - // No marker found — use everything - name - }; - - // Replace dots and underscores with spaces - let cleaned = RE_DOTS_UNDERSCORES.replace_all(title_part, " "); - let trimmed = cleaned.trim(); - - // Remove trailing hyphens/dashes left over - trimmed - .trim_end_matches(|c: char| c == '-' || c.is_whitespace()) - .to_string() -} - -/// Clean a title for comparison: lowercase, strip non-alphanumeric except -/// spaces, and collapse multiple spaces. -pub fn clean_title(s: &str) -> String { - let lower = s.to_lowercase(); - let stripped = RE_NON_ALNUM.replace_all(&lower, ""); - let collapsed = RE_MULTI_SPACE.replace_all(&stripped, " "); - collapsed.trim().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_title_standard() { - assert_eq!( - parse_title("Show.Name.S01E01.720p.HDTV.x264-GROUP"), - "Show Name" - ); - } - - #[test] - fn test_parse_title_with_year() { - assert_eq!( - parse_title("Movie.Name.2024.1080p.BluRay.x264-GROUP"), - "Movie Name" - ); - } - - #[test] - fn test_parse_title_underscores() { - assert_eq!( - parse_title("Show_Name_S01E01_720p_HDTV_x264-GROUP"), - "Show Name" - ); - } - - #[test] - fn test_parse_title_spaces() { - assert_eq!( - parse_title("Show Name S01E01 720p HDTV x264-GROUP"), - "Show Name" - ); - } - - #[test] - fn test_parse_title_daily() { - assert_eq!( - parse_title("Talk.Show.2024.01.15.720p.HDTV.x264-GROUP"), - "Talk Show" - ); - } - - #[test] - fn test_parse_title_multi_word() { - assert_eq!( - parse_title("The.Great.British.Bake.Off.S01E01.720p.HDTV.x264-GROUP"), - "The Great British Bake Off" - ); - } - - #[test] - fn test_clean_title_basic() { - assert_eq!(clean_title("Show Name"), "show name"); - } - - #[test] - fn test_clean_title_special_chars() { - assert_eq!(clean_title("Mr. Robot"), "mr robot"); - } - - #[test] - fn test_clean_title_extra_spaces() { - assert_eq!(clean_title(" Show Name "), "show name"); - } - - #[test] - fn test_clean_title_mixed() { - assert_eq!( - clean_title("Marvel's Agents of S.H.I.E.L.D."), - "marvels agents of shield" - ); - } - - // ── Tests documenting clean_title behavior on folder names ──────────── - // Disk folder names typically include year and imdb tags. - // These tests show that clean_title alone cannot match Sonarr/Radarr- - // imported clean_titles, which is why disk_scan uses path-based fallback. - - #[test] - fn test_clean_title_folder_with_year() { - // Folder: "Killers of the Flower Moon (2023)" - // Radarr DB clean_title: "killersflowermoon" (strips articles + no year) - // Our clean_title keeps articles and includes year digits - assert_eq!( - clean_title("Killers of the Flower Moon (2023)"), - "killers of the flower moon 2023" - ); - } - - #[test] - fn test_clean_title_folder_with_year_and_imdb() { - // Sonarr folder: "The Orville (2017) [imdb-tt5691552]" - // Sonarr DB clean_title: "theorville" - // Our clean_title retains all alphanumeric content - assert_eq!( - clean_title("The Orville (2017) [imdb-tt5691552]"), - "the orville 2017 imdbtt5691552" - ); - } - - #[test] - fn test_clean_title_folder_colon_in_title() { - // Folder: "13 Hours The Secret Soldiers of Benghazi (2016)" - // Radarr DB clean_title: "13hourssecretsoldiersbenghazi" - // Our clean_title preserves articles - assert_eq!( - clean_title("13 Hours The Secret Soldiers of Benghazi (2016)"), - "13 hours the secret soldiers of benghazi 2016" - ); - } - - #[test] - fn test_clean_title_simple_movie_name() { - assert_eq!(clean_title("The Creator"), "the creator"); - } - - #[test] - fn test_clean_title_numeric_title() { - assert_eq!(clean_title("1883 (2021) [imdb-tt13991232]"), "1883 2021 imdbtt13991232"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/Cargo.toml deleted file mode 100644 index 0055bd23..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "stackarr-plex" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-metadata = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -regex = { workspace = true } -sqlx = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -uuid = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/api.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/api.rs deleted file mode 100644 index 3b540911..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/api.rs +++ /dev/null @@ -1,307 +0,0 @@ -use anyhow::{Context, Result}; -use reqwest::header::{HeaderMap, HeaderValue}; - -use crate::types::*; - -// ── Plex API (direct server) ─────────────────────────────────────────────── - -/// Client for communicating directly with a Plex Media Server. -pub struct PlexApi { - client: reqwest::Client, - base_url: String, - token: String, -} - -impl PlexApi { - pub fn new(ip: &str, port: i32, use_ssl: bool, token: &str) -> Self { - Self::with_tls_verify(ip, port, use_ssl, token, true) - } - - /// Create a Plex API client with configurable TLS certificate verification. - /// When `verify_tls` is false, self-signed certificates are accepted. - pub fn with_tls_verify( - ip: &str, - port: i32, - use_ssl: bool, - token: &str, - verify_tls: bool, - ) -> Self { - let scheme = if use_ssl { "https" } else { "http" }; - Self { - client: reqwest::Client::builder() - .danger_accept_invalid_certs(!verify_tls) - .timeout(std::time::Duration::from_secs(30)) - .build() - .unwrap_or_default(), - base_url: format!("{scheme}://{ip}:{port}"), - token: token.to_string(), - } - } - - pub fn from_server(server: &PlexServer) -> Option { - let token = server.auth_token.as_ref()?; - // Default to not verifying certs for Plex servers (commonly self-signed) - Some(Self::with_tls_verify( - &server.ip, - server.port, - server.use_ssl, - token, - false, - )) - } - - fn headers(&self) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert("X-Plex-Token", HeaderValue::from_str(&self.token).unwrap()); - headers.insert("Accept", HeaderValue::from_static("application/json")); - headers.insert("X-Plex-Product", HeaderValue::from_static("StackArr")); - headers.insert("X-Plex-Device-Name", HeaderValue::from_static("StackArr")); - headers - } - - /// Get server info (machine ID, friendly name, version). - pub async fn get_status(&self) -> Result { - let resp: PlexMediaContainer = self - .client - .get(&self.base_url) - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.media_container) - } - - /// List all library sections on the server. - pub async fn get_libraries(&self) -> Result> { - let url = format!("{}/library/sections", self.base_url); - let resp: PlexMediaContainer = self - .client - .get(&url) - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.media_container.directory) - } - - /// Get all items in a library section (paginated). - pub async fn get_library_contents( - &self, - section_id: &str, - start: i64, - size: i64, - ) -> Result { - let url = format!( - "{}/library/sections/{section_id}/all?X-Plex-Container-Start={start}&X-Plex-Container-Size={size}", - self.base_url - ); - let resp: PlexMediaContainer = self - .client - .get(&url) - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.media_container) - } - - /// Get metadata for a single item by rating key. - pub async fn get_metadata(&self, rating_key: &str) -> Result { - let url = format!("{}/library/metadata/{rating_key}", self.base_url); - let resp: PlexMediaContainer = self - .client - .get(&url) - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - resp.media_container - .metadata - .into_iter() - .next() - .context("no metadata returned for rating key") - } - - /// Get children (seasons for a show, episodes for a season). - pub async fn get_children(&self, rating_key: &str) -> Result> { - let url = format!("{}/library/metadata/{rating_key}/children", self.base_url); - let resp: PlexMediaContainer = self - .client - .get(&url) - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.media_container.metadata) - } - - /// Get recently added items in a library section, sorted by addedAt descending. - pub async fn get_recently_added( - &self, - section_id: &str, - start: i64, - size: i64, - ) -> Result { - let url = format!( - "{}/library/sections/{section_id}/all?sort=addedAt:desc&X-Plex-Container-Start={start}&X-Plex-Container-Size={size}", - self.base_url - ); - let resp: PlexMediaContainer = self - .client - .get(&url) - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.media_container) - } -} - -// ── PlexTV API (plex.tv) ─────────────────────────────────────────────────── - -/// Client for the plex.tv cloud API (authentication, watchlist, device discovery). -pub struct PlexTvApi { - client: reqwest::Client, - token: String, -} - -impl PlexTvApi { - pub fn new(token: &str) -> Self { - Self { - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .unwrap_or_default(), - token: token.to_string(), - } - } - - fn headers(&self) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert("X-Plex-Token", HeaderValue::from_str(&self.token).unwrap()); - headers.insert("Accept", HeaderValue::from_static("application/json")); - headers.insert("X-Plex-Product", HeaderValue::from_static("StackArr")); - headers.insert( - "X-Plex-Client-Identifier", - HeaderValue::from_static("stackarr-server"), - ); - headers - } - - /// Validate the token is still active (ping). - pub async fn ping_token(&self) -> Result { - let resp = self - .client - .get("https://plex.tv/api/v2/ping") - .headers(self.headers()) - .send() - .await?; - Ok(resp.status().is_success()) - } - - /// Get the authenticated user's account info. - pub async fn get_user(&self) -> Result { - let resp: PlexTvUserContainer = self - .client - .get("https://plex.tv/users/account.json") - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(resp.user) - } - - /// Discover Plex servers the user owns/has access to. - pub async fn get_servers(&self) -> Result> { - let resources: Vec = self - .client - .get("https://plex.tv/api/resources?includeHttps=1") - .headers(self.headers()) - .send() - .await? - .error_for_status()? - .json() - .await?; - // Filter to only "server" providers - Ok(resources - .into_iter() - .filter(|r| r.provides.contains("server")) - .collect()) - } - - /// Get the user's Plex watchlist. - pub async fn get_watchlist(&self) -> Result> { - let url = "https://discover.provider.plex.tv/library/sections/watchlist/all"; - let resp = self - .client - .get(url) - .headers(self.headers()) - .send() - .await?; - - if !resp.status().is_success() { - // 404 or empty watchlist - return Ok(Vec::new()); - } - - let container: PlexMediaContainer = resp.json().await?; - Ok(container.media_container.metadata) - } - - /// Create a PIN for the OAuth flow. - pub async fn create_pin(&self, client_id: &str) -> Result { - let mut headers = HeaderMap::new(); - headers.insert("Accept", HeaderValue::from_static("application/json")); - headers.insert("X-Plex-Product", HeaderValue::from_static("StackArr")); - headers.insert( - "X-Plex-Client-Identifier", - HeaderValue::from_str(client_id).unwrap(), - ); - - let pin: PlexPinResponse = self - .client - .post("https://plex.tv/api/v2/pins?strong=true") - .headers(headers) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(pin) - } - - /// Check if a PIN has been authorized (poll during OAuth flow). - pub async fn check_pin(&self, pin_id: i64, client_id: &str) -> Result { - let mut headers = HeaderMap::new(); - headers.insert("Accept", HeaderValue::from_static("application/json")); - headers.insert( - "X-Plex-Client-Identifier", - HeaderValue::from_str(client_id).unwrap(), - ); - - let pin: PlexPinResponse = self - .client - .get(format!("https://plex.tv/api/v2/pins/{pin_id}")) - .headers(headers) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(pin) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/guid.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/guid.rs deleted file mode 100644 index 96e03902..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/guid.rs +++ /dev/null @@ -1,122 +0,0 @@ -use regex::Regex; -use std::sync::LazyLock; - -use crate::types::{ExtractedIds, PlexGuid}; - -// Compiled regex patterns for extracting external IDs from Plex GUID strings. -static RE_TMDB: LazyLock = LazyLock::new(|| Regex::new(r"tmdb://(\d+)").unwrap()); -static RE_IMDB: LazyLock = LazyLock::new(|| Regex::new(r"imdb://(tt\d+)").unwrap()); -static RE_TVDB: LazyLock = LazyLock::new(|| Regex::new(r"tvdb://(\d+)").unwrap()); -static RE_HAMA_TVDB: LazyLock = - LazyLock::new(|| Regex::new(r"hama://tvdb\d?-(\d+)").unwrap()); -static RE_HAMA_ANIDB: LazyLock = - LazyLock::new(|| Regex::new(r"hama://anidb\d?-(\d+)").unwrap()); - -/// Extract TMDB, IMDB, and TVDB IDs from Plex GUID entries. -/// -/// Plex stores external IDs in the `Guid` array on metadata items, e.g.: -/// - `tmdb://12345` -/// - `imdb://tt1234567` -/// - `tvdb://67890` -/// - `hama://tvdb3-12345` (anime agents) -pub fn extract_ids(guids: &[PlexGuid]) -> ExtractedIds { - let mut ids = ExtractedIds::default(); - - for guid in guids { - let s = &guid.id; - - if ids.tmdb_id.is_none() { - if let Some(cap) = RE_TMDB.captures(s) { - ids.tmdb_id = cap[1].parse().ok(); - } - } - if ids.imdb_id.is_none() { - if let Some(cap) = RE_IMDB.captures(s) { - ids.imdb_id = Some(cap[1].to_string()); - } - } - if ids.tvdb_id.is_none() { - if let Some(cap) = RE_TVDB.captures(s) { - ids.tvdb_id = cap[1].parse().ok(); - } - // Fallback: HAMA agent for anime - if ids.tvdb_id.is_none() { - if let Some(cap) = RE_HAMA_TVDB.captures(s) { - ids.tvdb_id = cap[1].parse().ok(); - } - } - } - } - - ids -} - -/// Extract IDs from the legacy top-level `guid` field (older Plex agents). -/// Format: `com.plexapp.agents.themoviedb://12345?lang=en` -pub fn extract_ids_from_legacy_guid(guid: &str) -> ExtractedIds { - let mut ids = ExtractedIds::default(); - - if guid.contains("themoviedb://") || guid.contains("tmdb://") { - if let Some(cap) = RE_TMDB - .captures(guid) - .or_else(|| Regex::new(r"themoviedb://(\d+)").unwrap().captures(guid)) - { - ids.tmdb_id = cap[1].parse().ok(); - } - } else if guid.contains("imdb://") || guid.contains("thetvdb://") { - if let Some(cap) = RE_IMDB.captures(guid) { - ids.imdb_id = Some(cap[1].to_string()); - } - if let Some(cap) = Regex::new(r"thetvdb://(\d+)") - .unwrap() - .captures(guid) - .or_else(|| RE_TVDB.captures(guid)) - { - ids.tvdb_id = cap[1].parse().ok(); - } - } else if guid.contains("hama://") { - if let Some(cap) = RE_HAMA_TVDB.captures(guid) { - ids.tvdb_id = cap[1].parse().ok(); - } - if let Some(cap) = RE_HAMA_ANIDB.captures(guid) { - // Store AniDB in tvdb_id field for now — downstream can map it - ids.tvdb_id = cap[1].parse().ok(); - } - } - - ids -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_tmdb_imdb_tvdb() { - let guids = vec![ - PlexGuid { id: "tmdb://550".to_string() }, - PlexGuid { id: "imdb://tt0137523".to_string() }, - PlexGuid { id: "tvdb://81189".to_string() }, - ]; - let ids = extract_ids(&guids); - assert_eq!(ids.tmdb_id, Some(550)); - assert_eq!(ids.imdb_id, Some("tt0137523".to_string())); - assert_eq!(ids.tvdb_id, Some(81189)); - } - - #[test] - fn test_extract_hama() { - let guids = vec![PlexGuid { - id: "hama://tvdb3-12345".to_string(), - }]; - let ids = extract_ids(&guids); - assert_eq!(ids.tvdb_id, Some(12345)); - } - - #[test] - fn test_legacy_guid() { - let ids = - extract_ids_from_legacy_guid("com.plexapp.agents.themoviedb://550?lang=en"); - assert_eq!(ids.tmdb_id, Some(550)); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/lib.rs deleted file mode 100644 index ecac5a0a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod api; -pub mod guid; -pub mod scanner; -pub mod sync; -pub mod types; - -pub use api::{PlexApi, PlexTvApi}; -pub use scanner::PlexScanner; -pub use sync::{AvailabilitySync, TokenRefresh, WatchlistSync}; -pub use types::*; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/scanner.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/scanner.rs deleted file mode 100644 index 14df579c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/scanner.rs +++ /dev/null @@ -1,299 +0,0 @@ -use anyhow::Result; -use sqlx::PgPool; - -use crate::api::PlexApi; -use crate::guid; -use crate::types::*; - -const PAGE_SIZE: i64 = 50; - -/// Scans Plex libraries and updates local media availability. -pub struct PlexScanner { - pool: PgPool, -} - -impl PlexScanner { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - /// Full scan of all enabled Plex libraries. Updates availability for every item. - pub async fn full_scan(&self) -> Result { - let mut report = ScanReport::default(); - - let servers = self.load_servers().await?; - for server in &servers { - let Some(api) = PlexApi::from_server(server) else { - tracing::warn!(server_id = server.id, "no auth token for plex server, skipping"); - continue; - }; - - let libraries = self.load_enabled_libraries(server.id).await?; - for lib in &libraries { - tracing::info!( - server = %server.name, - library = %lib.name, - "starting full scan" - ); - - let mut start = 0i64; - loop { - let container = api - .get_library_contents(&lib.section_id, start, PAGE_SIZE) - .await; - - let container = match container { - Ok(c) => c, - Err(e) => { - tracing::error!( - library = %lib.name, - error = %e, - "failed to fetch library contents" - ); - report.errors += 1; - break; - } - }; - - let count = container.metadata.len() as i64; - for item in &container.metadata { - self.process_item(item, &lib.library_type, &mut report) - .await; - } - - start += count; - let total = container.total_size.unwrap_or(0); - if count == 0 || start >= total { - break; - } - } - - // Update last_scan timestamp - let _ = sqlx::query( - "UPDATE plex_libraries SET last_scan = NOW() WHERE id = $1", - ) - .bind(lib.id) - .execute(&self.pool) - .await; - - tracing::info!( - library = %lib.name, - scanned = report.items_scanned, - updated = report.items_updated, - "full scan complete for library" - ); - } - } - - Ok(report) - } - - /// Incremental scan — only items added since the last scan. - pub async fn recent_scan(&self) -> Result { - let mut report = ScanReport::default(); - - let servers = self.load_servers().await?; - for server in &servers { - let Some(api) = PlexApi::from_server(server) else { - continue; - }; - - let libraries = self.load_enabled_libraries(server.id).await?; - for lib in &libraries { - let last_scan_ts = lib - .last_scan - .map(|dt| dt.timestamp()) - .unwrap_or(0); - - // Add a 10-minute buffer to catch items that were being processed - let since_ts = last_scan_ts - 600; - - let mut start = 0i64; - let mut seen_keys = std::collections::HashSet::new(); - - loop { - let container = match api - .get_recently_added(&lib.section_id, start, PAGE_SIZE) - .await - { - Ok(c) => c, - Err(e) => { - tracing::error!( - library = %lib.name, - error = %e, - "failed to fetch recently added" - ); - report.errors += 1; - break; - } - }; - - let count = container.metadata.len() as i64; - let mut all_older = true; - - for item in &container.metadata { - let added_at = item.added_at.unwrap_or(0); - if added_at < since_ts { - all_older = true; - continue; - } - all_older = false; - - // Deduplicate by rating key - if !seen_keys.insert(item.rating_key.clone()) { - continue; - } - - self.process_item(item, &lib.library_type, &mut report) - .await; - } - - start += count; - if count == 0 || all_older { - break; - } - } - - // Update last_scan - let _ = sqlx::query( - "UPDATE plex_libraries SET last_scan = NOW() WHERE id = $1", - ) - .bind(lib.id) - .execute(&self.pool) - .await; - } - } - - if report.items_updated > 0 { - tracing::info!( - scanned = report.items_scanned, - updated = report.items_updated, - "plex recent scan complete" - ); - } - - Ok(report) - } - - /// Process a single Plex metadata item — extract IDs and update local DB. - async fn process_item( - &self, - item: &PlexMetadataItem, - library_type: &str, - report: &mut ScanReport, - ) { - report.items_scanned += 1; - - // Extract external IDs from Guid array - let mut ids = guid::extract_ids(&item.guids); - - // Fallback to legacy guid field - if ids.tmdb_id.is_none() && ids.imdb_id.is_none() && ids.tvdb_id.is_none() { - if let Some(ref legacy) = item.guid { - ids = guid::extract_ids_from_legacy_guid(legacy); - } - } - - // We need at least a TMDB ID to link - let Some(tmdb_id) = ids.tmdb_id else { - // Try IMDB/TVDB lookup later — for now skip - tracing::debug!( - rating_key = %item.rating_key, - title = %item.title, - "no TMDB ID found, skipping" - ); - return; - }; - - let is_4k = is_4k(&item.media); - let added_at = item - .added_at - .and_then(|ts| { - chrono::DateTime::from_timestamp(ts, 0) - }); - - match library_type { - "movie" => { - let (rk_col, rk_val) = if is_4k { - ("plex_rating_key_4k", &item.rating_key) - } else { - ("plex_rating_key", &item.rating_key) - }; - - let query = format!( - "UPDATE movies SET {rk_col} = $1, media_added_at = COALESCE($2, media_added_at) \ - WHERE tmdb_id = $3" - ); - let result = sqlx::query(&query) - .bind(rk_val) - .bind(added_at) - .bind(tmdb_id) - .execute(&self.pool) - .await; - - if let Ok(r) = result { - if r.rows_affected() > 0 { - report.items_updated += 1; - } - } - } - "show" => { - let (rk_col, rk_val) = if is_4k { - ("plex_rating_key_4k", &item.rating_key) - } else { - ("plex_rating_key", &item.rating_key) - }; - - let query = format!( - "UPDATE series SET {rk_col} = $1, media_added_at = COALESCE($2, media_added_at) \ - WHERE tmdb_id = $3" - ); - let result = sqlx::query(&query) - .bind(rk_val) - .bind(added_at) - .bind(tmdb_id) - .execute(&self.pool) - .await; - - if let Ok(r) = result { - if r.rows_affected() > 0 { - report.items_updated += 1; - } - } - } - _ => {} - } - } - - // ── DB helpers ────────────────────────────────────────────────────────── - - async fn load_servers(&self) -> Result> { - let servers = sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at \ - FROM plex_servers ORDER BY id", - ) - .fetch_all(&self.pool) - .await?; - Ok(servers) - } - - async fn load_enabled_libraries(&self, server_id: i64) -> Result> { - let libs = sqlx::query_as::<_, PlexLibrary>( - "SELECT id, plex_server_id, section_id, name, enabled, library_type, last_scan \ - FROM plex_libraries WHERE plex_server_id = $1 AND enabled = true ORDER BY id", - ) - .bind(server_id) - .fetch_all(&self.pool) - .await?; - Ok(libs) - } -} - -// ── Report ───────────────────────────────────────────────────────────────── - -#[derive(Debug, Default, serde::Serialize)] -pub struct ScanReport { - pub items_scanned: u64, - pub items_updated: u64, - pub errors: u64, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/sync.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/sync.rs deleted file mode 100644 index db80fef1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/sync.rs +++ /dev/null @@ -1,299 +0,0 @@ -use anyhow::Result; -use sqlx::PgPool; - -use crate::api::{PlexApi, PlexTvApi}; -use crate::guid; -use crate::types::*; - -// ── Availability Sync ────────────────────────────────────────────────────── - -/// Verifies that media marked as available in Plex still exists. -/// Runs every 24 hours to detect items removed from Plex libraries. -pub struct AvailabilitySync { - pool: PgPool, -} - -impl AvailabilitySync { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn run(&self) -> Result { - let mut report = AvailabilitySyncReport::default(); - - let servers = sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at \ - FROM plex_servers ORDER BY id", - ) - .fetch_all(&self.pool) - .await?; - - for server in &servers { - let Some(api) = PlexApi::from_server(server) else { - continue; - }; - - // Check movies with plex_rating_key set - let movies: Vec<(i64, String, Option)> = sqlx::query_as( - "SELECT id, COALESCE(plex_rating_key, ''), plex_rating_key_4k \ - FROM movies WHERE plex_rating_key IS NOT NULL OR plex_rating_key_4k IS NOT NULL", - ) - .fetch_all(&self.pool) - .await?; - - for (movie_id, rk, rk_4k) in &movies { - report.checked += 1; - - // Check standard quality - if !rk.is_empty() { - if !self.item_exists_in_plex(&api, rk, false).await { - tracing::info!(movie_id, rating_key = %rk, "movie no longer in Plex, clearing"); - let _ = sqlx::query( - "UPDATE movies SET plex_rating_key = NULL WHERE id = $1", - ) - .bind(movie_id) - .execute(&self.pool) - .await; - report.removed += 1; - } - } - - // Check 4K - if let Some(rk4) = rk_4k { - if !self.item_exists_in_plex(&api, rk4, true).await { - let _ = sqlx::query( - "UPDATE movies SET plex_rating_key_4k = NULL WHERE id = $1", - ) - .bind(movie_id) - .execute(&self.pool) - .await; - report.removed += 1; - } - } - } - - // Check series - let series: Vec<(i64, String, Option)> = sqlx::query_as( - "SELECT id, COALESCE(plex_rating_key, ''), plex_rating_key_4k \ - FROM series WHERE plex_rating_key IS NOT NULL OR plex_rating_key_4k IS NOT NULL", - ) - .fetch_all(&self.pool) - .await?; - - for (series_id, rk, rk_4k) in &series { - report.checked += 1; - - if !rk.is_empty() { - if !self.item_exists_in_plex(&api, rk, false).await { - tracing::info!(series_id, rating_key = %rk, "series no longer in Plex, clearing"); - let _ = sqlx::query( - "UPDATE series SET plex_rating_key = NULL WHERE id = $1", - ) - .bind(series_id) - .execute(&self.pool) - .await; - report.removed += 1; - } - } - - if let Some(rk4) = rk_4k { - if !self.item_exists_in_plex(&api, rk4, true).await { - let _ = sqlx::query( - "UPDATE series SET plex_rating_key_4k = NULL WHERE id = $1", - ) - .bind(series_id) - .execute(&self.pool) - .await; - report.removed += 1; - } - } - } - } - - if report.removed > 0 { - tracing::info!( - checked = report.checked, - removed = report.removed, - "availability sync complete" - ); - } - - Ok(report) - } - - async fn item_exists_in_plex(&self, api: &PlexApi, rating_key: &str, require_4k: bool) -> bool { - match api.get_metadata(rating_key).await { - Ok(item) => { - if require_4k { - is_4k(&item.media) - } else { - true - } - } - Err(_) => false, - } - } -} - -#[derive(Debug, Default, serde::Serialize)] -pub struct AvailabilitySyncReport { - pub checked: u64, - pub removed: u64, -} - -// ── Watchlist Sync ───────────────────────────────────────────────────────── - -/// Syncs Plex watchlists and optionally auto-adds items to the library. -pub struct WatchlistSync { - pool: PgPool, -} - -impl WatchlistSync { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn run(&self) -> Result { - let mut report = WatchlistSyncReport::default(); - - // Get all plex servers with tokens - let tokens: Vec<(String,)> = sqlx::query_as( - "SELECT DISTINCT auth_token FROM plex_servers WHERE auth_token IS NOT NULL", - ) - .fetch_all(&self.pool) - .await?; - - for (token,) in &tokens { - let tv_api = PlexTvApi::new(token); - - let watchlist = match tv_api.get_watchlist().await { - Ok(wl) => wl, - Err(e) => { - tracing::warn!(error = %e, "failed to fetch Plex watchlist"); - continue; - } - }; - - for item in &watchlist { - report.total += 1; - - // Extract TMDB ID from watchlist item GUIDs - let ids = guid::extract_ids(&item.guids); - let Some(tmdb_id) = ids.tmdb_id else { - // Try legacy guid - let ids = item - .guid - .as_deref() - .map(guid::extract_ids_from_legacy_guid) - .unwrap_or_default(); - let Some(tmdb_id) = ids.tmdb_id else { - continue; - }; - self.upsert_watchlist_entry( - tmdb_id, - &item.item_type, - &item.rating_key, - &mut report, - ) - .await; - continue; - }; - - self.upsert_watchlist_entry( - tmdb_id, - &item.item_type, - &item.rating_key, - &mut report, - ) - .await; - } - } - - if report.new_entries > 0 { - tracing::info!( - total = report.total, - new = report.new_entries, - "watchlist sync complete" - ); - } - - Ok(report) - } - - async fn upsert_watchlist_entry( - &self, - tmdb_id: i64, - media_type: &str, - rating_key: &str, - report: &mut WatchlistSyncReport, - ) { - let media_type_normalized = match media_type { - "show" => "tv", - other => other, - }; - - let result = sqlx::query( - "INSERT INTO watchlist (tmdb_id, media_type, plex_rating_key) \ - VALUES ($1, $2, $3) \ - ON CONFLICT (tmdb_id, media_type) DO NOTHING", - ) - .bind(tmdb_id) - .bind(media_type_normalized) - .bind(rating_key) - .execute(&self.pool) - .await; - - if let Ok(r) = result { - if r.rows_affected() > 0 { - report.new_entries += 1; - } - } - } -} - -#[derive(Debug, Default, serde::Serialize)] -pub struct WatchlistSyncReport { - pub total: u64, - pub new_entries: u64, -} - -// ── Token Refresh ────────────────────────────────────────────────────────── - -/// Periodically pings plex.tv to keep auth tokens from expiring. -pub struct TokenRefresh { - pool: PgPool, -} - -impl TokenRefresh { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn run(&self) -> Result { - let servers: Vec<(i64, String)> = sqlx::query_as( - "SELECT id, auth_token FROM plex_servers WHERE auth_token IS NOT NULL", - ) - .fetch_all(&self.pool) - .await?; - - let mut refreshed = 0u64; - - for (server_id, token) in &servers { - let tv_api = PlexTvApi::new(token); - match tv_api.ping_token().await { - Ok(true) => { - refreshed += 1; - tracing::debug!(server_id, "plex token refreshed successfully"); - } - Ok(false) => { - tracing::warn!(server_id, "plex token ping returned non-success"); - } - Err(e) => { - tracing::warn!(server_id, error = %e, "failed to refresh plex token"); - } - } - } - - Ok(refreshed) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/types.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/types.rs deleted file mode 100644 index beb38b86..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-plex/src/types.rs +++ /dev/null @@ -1,244 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::FromRow; - -// ── Plex API response types ──────────────────────────────────────────────── - -/// Top-level Plex XML/JSON container. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexMediaContainer { - #[serde(rename = "MediaContainer")] - pub media_container: T, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexServerInfo { - pub machine_identifier: Option, - pub friendly_name: Option, - pub version: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexLibrariesContainer { - #[serde(default, rename = "Directory")] - pub directory: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexLibrarySection { - pub key: String, - pub title: String, - #[serde(rename = "type")] - pub section_type: String, // "movie" or "show" - pub agent: Option, - pub scanner: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexItemsContainer { - #[serde(default)] - pub total_size: Option, - #[serde(default, rename = "Metadata")] - pub metadata: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexMetadataItem { - pub rating_key: String, - #[serde(default)] - pub parent_rating_key: Option, - #[serde(default)] - pub grandparent_rating_key: Option, - pub title: String, - #[serde(rename = "type")] - pub item_type: String, // "movie", "show", "season", "episode" - pub guid: Option, - #[serde(default, rename = "Guid")] - pub guids: Vec, - #[serde(default)] - pub added_at: Option, // unix timestamp - #[serde(default, rename = "Media")] - pub media: Vec, - // Season/episode info - #[serde(default)] - pub leaf_count: Option, - #[serde(default)] - pub viewed_leaf_count: Option, - #[serde(default, rename = "Children")] - pub children: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexChildrenContainer { - #[serde(default, rename = "Metadata")] - pub metadata: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PlexGuid { - pub id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexMediaInfo { - #[serde(default)] - pub width: Option, - #[serde(default)] - pub height: Option, - #[serde(default)] - pub video_resolution: Option, -} - -// ── Plex.tv types ────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PlexTvUser { - pub id: i64, - pub uuid: String, - pub username: String, - pub email: Option, - pub thumb: Option, - pub title: Option, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PlexTvUserContainer { - pub user: PlexTvUser, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexResource { - pub name: String, - pub client_identifier: String, - pub provides: String, - #[serde(default)] - pub connections: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexConnection { - pub uri: String, - pub local: bool, - pub protocol: String, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PlexPinResponse { - pub id: i64, - pub code: String, - #[serde(rename = "authToken")] - pub auth_token: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexWatchlistContainer { - #[serde(default, rename = "Metadata")] - pub metadata: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PlexWatchlistItem { - pub rating_key: String, - pub title: String, - #[serde(rename = "type")] - pub item_type: String, // "movie" or "show" - pub guid: Option, - #[serde(default, rename = "Guid")] - pub guids: Vec, -} - -// ── Database models ──────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct PlexServer { - pub id: i64, - pub name: String, - pub machine_id: Option, - pub ip: String, - pub port: i32, - pub use_ssl: bool, - pub auth_token: Option, - pub web_app_url: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct PlexLibrary { - pub id: i64, - pub plex_server_id: i64, - pub section_id: String, - pub name: String, - pub enabled: bool, - pub library_type: String, // "movie" or "show" - pub last_scan: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -#[serde(rename_all = "camelCase")] -pub struct WatchlistEntry { - pub id: i64, - pub tmdb_id: i64, - pub media_type: String, - pub plex_rating_key: Option, - pub auto_requested: bool, - pub created_at: DateTime, -} - -// ── Input types ──────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreatePlexServerInput { - pub name: Option, - pub ip: String, - pub port: Option, - pub use_ssl: Option, - pub auth_token: String, - pub web_app_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdatePlexServerInput { - pub name: Option, - pub ip: Option, - pub port: Option, - pub use_ssl: Option, - pub auth_token: Option, - pub web_app_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdatePlexLibraryInput { - pub enabled: bool, -} - -// ── Extracted IDs from Plex GUIDs ────────────────────────────────────────── - -#[derive(Debug, Clone, Default)] -pub struct ExtractedIds { - pub tmdb_id: Option, - pub imdb_id: Option, - pub tvdb_id: Option, -} - -/// Whether a Plex item is 4K quality. -pub fn is_4k(media: &[PlexMediaInfo]) -> bool { - media.iter().any(|m| m.width.unwrap_or(0) >= 2000) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/Cargo.toml deleted file mode 100644 index 81a9dccb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "stackarr-quality" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-parser = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -regex = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/src/custom_formats.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/src/custom_formats.rs deleted file mode 100644 index af01397a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/src/custom_formats.rs +++ /dev/null @@ -1,509 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; - -use regex::Regex; -use serde::{Deserialize, Serialize}; - -// ── Types ────────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CustomFormatDef { - pub id: i64, - pub name: String, - pub specifications: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FormatSpec { - pub field: FormatField, - pub pattern: String, - pub negate: bool, - pub required: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FormatField { - ReleaseName, - Quality, - Language, - ReleaseGroup, - IndexerFlag, - Size, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CustomFormatResult { - pub total_score: i32, - pub matched_formats: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MatchedFormat { - pub format_id: i64, - pub format_name: String, - pub score: i32, -} - -// ── Regex cache ──────────────────────────────────────────────────────────── - -/// Thread-safe cache for compiled regexes keyed by pattern string. -struct RegexCache { - inner: Mutex>, -} - -impl RegexCache { - fn new() -> Self { - Self { - inner: Mutex::new(HashMap::new()), - } - } - - fn get_or_compile(&self, pattern: &str) -> Result { - let mut map = self.inner.lock().unwrap(); - if let Some(re) = map.get(pattern) { - return Ok(re.clone()); - } - let re = Regex::new(pattern)?; - map.insert(pattern.to_string(), re.clone()); - Ok(re) - } -} - -// ── Engine ───────────────────────────────────────────────────────────────── - -pub struct CustomFormatEngine { - cache: RegexCache, -} - -impl Default for CustomFormatEngine { - fn default() -> Self { - Self::new() - } -} - -impl CustomFormatEngine { - pub fn new() -> Self { - Self { - cache: RegexCache::new(), - } - } - - /// Score a release against all custom formats. - /// Returns list of matched format IDs with their names. - pub fn score_release( - &self, - release_title: &str, - formats: &[CustomFormatDef], - profile_scores: &[(i64, i32)], // (format_id, score) - ) -> CustomFormatResult { - let score_map: HashMap = - profile_scores.iter().copied().collect(); - - let mut matched_formats = Vec::new(); - let mut total_score: i32 = 0; - - for format in formats { - if self.matches_format(release_title, format) { - let score = score_map.get(&format.id).copied().unwrap_or(0); - total_score = total_score.saturating_add(score); - matched_formats.push(MatchedFormat { - format_id: format.id, - format_name: format.name.clone(), - score, - }); - } - } - - CustomFormatResult { - total_score, - matched_formats, - } - } - - /// Check if a release matches a single custom format definition. - /// - /// Rules: - /// - ALL required specs must match - /// - If there are any non-required specs, at least one must match - fn matches_format(&self, release_title: &str, format: &CustomFormatDef) -> bool { - if format.specifications.is_empty() { - return false; - } - - let (required, optional): (Vec<&FormatSpec>, Vec<&FormatSpec>) = format - .specifications - .iter() - .partition(|s| s.required); - - // All required specs must match - for spec in &required { - if !self.spec_matches(release_title, spec) { - return false; - } - } - - // If there are optional specs, at least one must match - if !optional.is_empty() { - let any_optional = optional.iter().any(|s| self.spec_matches(release_title, s)); - if !any_optional { - return false; - } - } - - true - } - - /// Test whether a single spec matches the release title. - fn spec_matches(&self, release_title: &str, spec: &FormatSpec) -> bool { - let raw_match = match spec.field { - FormatField::ReleaseName => self.regex_matches(release_title, &spec.pattern), - FormatField::Quality => { - // Compare the pattern against the release title as a quality string. - // In a full implementation this would parse the quality from the title - // and compare, but for now we treat it as a regex against the title. - self.regex_matches(release_title, &spec.pattern) - } - FormatField::ReleaseGroup => { - // Extract release group (last segment after the last dash) and match - let group = extract_release_group(release_title); - self.regex_matches(&group, &spec.pattern) - } - FormatField::Language => { - // Match language tokens in the release title - self.regex_matches(release_title, &spec.pattern) - } - FormatField::IndexerFlag => { - // Indexer flags are not in the release title; this would need - // additional context. For now, treat as not matching. - false - } - FormatField::Size => { - // Size comparison would need the file size as context. - // For now, treat as not matching. - false - } - }; - - if spec.negate { - !raw_match - } else { - raw_match - } - } - - fn regex_matches(&self, text: &str, pattern: &str) -> bool { - match self.cache.get_or_compile(pattern) { - Ok(re) => re.is_match(text), - Err(e) => { - tracing::warn!(pattern, error = %e, "Invalid regex in custom format spec"); - false - } - } - } -} - -/// Extract the release group from a release title. -/// Typically the last segment after a dash, e.g. "Title.S01E01.720p-GROUP" -> "GROUP" -fn extract_release_group(title: &str) -> String { - // Remove file extension if present - let base = title - .rsplit_once('.') - .filter(|(_, ext)| ext.len() <= 4) - .map(|(base, _)| base) - .unwrap_or(title); - - base.rsplit_once('-') - .map(|(_, group)| group.to_string()) - .unwrap_or_default() -} - -// ── Tests ────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn engine() -> CustomFormatEngine { - CustomFormatEngine::new() - } - - #[test] - fn test_single_required_spec_matches() { - let e = engine(); - let format = CustomFormatDef { - id: 1, - name: "Remux".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseName, - pattern: r"\b(REMUX)\b".to_string(), - negate: false, - required: true, - }], - }; - - let result = e.score_release( - "Movie.2024.1080p.REMUX.AVC.DTS-HD-GROUP", - &[format.clone()], - &[(1, 100)], - ); - assert_eq!(result.total_score, 100); - assert_eq!(result.matched_formats.len(), 1); - assert_eq!(result.matched_formats[0].format_name, "Remux"); - } - - #[test] - fn test_single_required_spec_no_match() { - let e = engine(); - let format = CustomFormatDef { - id: 1, - name: "Remux".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseName, - pattern: r"\b(REMUX)\b".to_string(), - negate: false, - required: true, - }], - }; - - let result = e.score_release( - "Movie.2024.1080p.BluRay.x264-GROUP", - &[format], - &[(1, 100)], - ); - assert_eq!(result.total_score, 0); - assert!(result.matched_formats.is_empty()); - } - - #[test] - fn test_multiple_required_specs_all_must_match() { - let e = engine(); - let format = CustomFormatDef { - id: 2, - name: "4K Remux".to_string(), - specifications: vec![ - FormatSpec { - field: FormatField::ReleaseName, - pattern: r"\b(REMUX)\b".to_string(), - negate: false, - required: true, - }, - FormatSpec { - field: FormatField::ReleaseName, - pattern: r"2160p".to_string(), - negate: false, - required: true, - }, - ], - }; - - // Both match - let result = e.score_release( - "Movie.2024.2160p.REMUX.HEVC-GROUP", - &[format.clone()], - &[(2, 200)], - ); - assert_eq!(result.total_score, 200); - assert_eq!(result.matched_formats.len(), 1); - - // Only one matches - let result = e.score_release( - "Movie.2024.1080p.REMUX.AVC-GROUP", - &[format], - &[(2, 200)], - ); - assert_eq!(result.total_score, 0); - assert!(result.matched_formats.is_empty()); - } - - #[test] - fn test_negate_spec() { - let e = engine(); - let format = CustomFormatDef { - id: 3, - name: "Not x265".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseName, - pattern: r"(?i)\bx265\b".to_string(), - negate: true, - required: true, - }], - }; - - // x265 present -> negated -> no match - let result = e.score_release( - "Movie.2024.1080p.BluRay.x265-GROUP", - &[format.clone()], - &[(3, 50)], - ); - assert_eq!(result.total_score, 0); - - // x265 absent -> negated -> match - let result = e.score_release( - "Movie.2024.1080p.BluRay.x264-GROUP", - &[format], - &[(3, 50)], - ); - assert_eq!(result.total_score, 50); - } - - #[test] - fn test_score_with_multiple_matching_formats() { - let e = engine(); - let formats = vec![ - CustomFormatDef { - id: 1, - name: "Remux".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseName, - pattern: r"\bREMUX\b".to_string(), - negate: false, - required: true, - }], - }, - CustomFormatDef { - id: 2, - name: "4K".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseName, - pattern: r"2160p".to_string(), - negate: false, - required: true, - }], - }, - ]; - - let result = e.score_release( - "Movie.2024.2160p.REMUX.HEVC-GROUP", - &formats, - &[(1, 100), (2, 50)], - ); - assert_eq!(result.total_score, 150); - assert_eq!(result.matched_formats.len(), 2); - } - - #[test] - fn test_empty_format_list_returns_zero() { - let e = engine(); - let result = e.score_release("Some.Release.Title", &[], &[]); - assert_eq!(result.total_score, 0); - assert!(result.matched_formats.is_empty()); - } - - #[test] - fn test_required_and_optional_specs() { - let e = engine(); - let format = CustomFormatDef { - id: 4, - name: "BluRay+Quality".to_string(), - specifications: vec![ - FormatSpec { - field: FormatField::ReleaseName, - pattern: r"(?i)\bBluRay\b".to_string(), - negate: false, - required: true, - }, - FormatSpec { - field: FormatField::Quality, - pattern: r"1080p".to_string(), - negate: false, - required: false, - }, - FormatSpec { - field: FormatField::Quality, - pattern: r"2160p".to_string(), - negate: false, - required: false, - }, - ], - }; - - // Required matches, one optional matches - let result = e.score_release( - "Movie.2024.1080p.BluRay.x264-GROUP", - &[format.clone()], - &[(4, 75)], - ); - assert_eq!(result.total_score, 75); - - // Required matches, no optional matches - let result = e.score_release( - "Movie.2024.720p.BluRay.x264-GROUP", - &[format.clone()], - &[(4, 75)], - ); - assert_eq!(result.total_score, 0); - - // Required doesn't match - let result = e.score_release( - "Movie.2024.1080p.WEB-DL-GROUP", - &[format], - &[(4, 75)], - ); - assert_eq!(result.total_score, 0); - } - - #[test] - fn test_release_group_extraction() { - assert_eq!(extract_release_group("Movie.2024.1080p-GROUP"), "GROUP"); - assert_eq!( - extract_release_group("Movie.2024.1080p-GROUP.mkv"), - "GROUP" - ); - assert_eq!(extract_release_group("NoGroup"), ""); - } - - #[test] - fn test_release_group_spec() { - let e = engine(); - let format = CustomFormatDef { - id: 5, - name: "Preferred Group".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseGroup, - pattern: r"^(FraMeSToR|EPSiLON)$".to_string(), - negate: false, - required: true, - }], - }; - - let result = e.score_release( - "Movie.2024.2160p.REMUX-FraMeSToR", - &[format.clone()], - &[(5, 150)], - ); - assert_eq!(result.total_score, 150); - - let result = e.score_release( - "Movie.2024.2160p.REMUX-NOGROUP", - &[format], - &[(5, 150)], - ); - assert_eq!(result.total_score, 0); - } - - #[test] - fn test_format_no_score_in_profile() { - let e = engine(); - let format = CustomFormatDef { - id: 99, - name: "Unscored".to_string(), - specifications: vec![FormatSpec { - field: FormatField::ReleaseName, - pattern: r".*".to_string(), - negate: false, - required: true, - }], - }; - - // Format matches but has no score entry -> defaults to 0 - let result = e.score_release("anything", &[format], &[]); - assert_eq!(result.total_score, 0); - assert_eq!(result.matched_formats.len(), 1); - assert_eq!(result.matched_formats[0].score, 0); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/src/lib.rs deleted file mode 100644 index 85db9499..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-quality/src/lib.rs +++ /dev/null @@ -1,1889 +0,0 @@ -pub mod custom_formats; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -use stackarr_core::models::{DownloadProtocol, QualityProfile, ReleaseInfo}; - -// ── Quality profile CRUD ──────────────────────────────────────────────────── - -#[derive(Clone)] -pub struct QualityProfileService { - pool: PgPool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateProfileInput { - pub name: String, - pub cutoff: i32, - #[serde(default = "default_true")] - pub upgrade_allowed: bool, - #[serde(default)] - pub min_format_score: i32, - #[serde(default)] - pub cutoff_format_score: i32, - pub items: serde_json::Value, - pub media_type: Option, - /// Language preference: -1=Any (default), -2=Original, positive=Radarr language ID. - #[serde(default = "default_language_any")] - pub language: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateProfileInput { - pub name: Option, - pub cutoff: Option, - pub upgrade_allowed: Option, - pub min_format_score: Option, - pub cutoff_format_score: Option, - pub items: Option, - pub media_type: Option, - pub language: Option, -} - -fn default_language_any() -> i32 { - -1 -} - -fn default_true() -> bool { - true -} - -impl QualityProfileService { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - pub async fn list(&self) -> Result> { - let mut rows = - sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles ORDER BY id") - .fetch_all(&self.pool) - .await?; - for p in &mut rows { - p.normalize_items(); - } - Ok(rows) - } - - pub async fn get(&self, id: i64) -> Result { - let mut row = - sqlx::query_as::<_, QualityProfile>("SELECT * FROM quality_profiles WHERE id = $1") - .bind(id) - .fetch_one(&self.pool) - .await?; - row.normalize_items(); - Ok(row) - } - - pub async fn create(&self, input: CreateProfileInput) -> Result { - let mut row = sqlx::query_as::<_, QualityProfile>( - "INSERT INTO quality_profiles (name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items, media_type, language) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *", - ) - .bind(&input.name) - .bind(input.cutoff) - .bind(input.upgrade_allowed) - .bind(input.min_format_score) - .bind(input.cutoff_format_score) - .bind(&input.items) - .bind(&input.media_type) - .bind(input.language) - .fetch_one(&self.pool) - .await?; - row.normalize_items(); - Ok(row) - } - - pub async fn update(&self, id: i64, input: UpdateProfileInput) -> Result { - let existing = self.get(id).await?; - let name = input.name.unwrap_or(existing.name); - let cutoff = input.cutoff.unwrap_or(existing.cutoff); - let upgrade = input.upgrade_allowed.unwrap_or(existing.upgrade_allowed); - let min_fs = input.min_format_score.unwrap_or(existing.min_format_score); - let cutoff_fs = input - .cutoff_format_score - .unwrap_or(existing.cutoff_format_score); - let items = input.items.unwrap_or(existing.items); - let media_type = if input.media_type.is_some() { - input.media_type - } else { - existing.media_type - }; - let language = input.language.unwrap_or(existing.language); - - let mut row = sqlx::query_as::<_, QualityProfile>( - "UPDATE quality_profiles SET name=$1, cutoff=$2, upgrade_allowed=$3, min_format_score=$4, cutoff_format_score=$5, items=$6, media_type=$7, language=$8 - WHERE id=$9 RETURNING *", - ) - .bind(&name) - .bind(cutoff) - .bind(upgrade) - .bind(min_fs) - .bind(cutoff_fs) - .bind(&items) - .bind(&media_type) - .bind(language) - .bind(id) - .fetch_one(&self.pool) - .await?; - row.normalize_items(); - Ok(row) - } - - pub async fn delete(&self, id: i64) -> Result<()> { - sqlx::query("DELETE FROM quality_profiles WHERE id = $1") - .bind(id) - .execute(&self.pool) - .await?; - Ok(()) - } -} - -// ── Decision context ──────────────────────────────────────────────────────── - -/// Full context passed to each decision specification. -#[derive(Debug, Clone)] -pub struct DecisionContext { - pub release: ReleaseInfo, - pub profile: QualityProfile, - /// Quality discriminant of the existing file on disk, if any. - pub existing_quality: Option, - /// Custom format score of the existing file, if any. - pub existing_custom_format_score: Option, - /// Custom format score computed for this release. - pub release_custom_format_score: i32, - /// Whether this release is already being downloaded. - pub in_queue: bool, - /// Whether this release was previously failed/blocklisted. - pub in_blocklist: bool, - /// Whether this release (by guid) has already been grabbed and imported. - pub already_grabbed: bool, - /// Quality of the highest-quality item in the queue for this media item. - /// `Some(quality_num)` when an equal-or-better queued item exists. - pub queued_quality: Option, - /// Radarr language ID of the media's original language (for -2/Original profiles). - pub original_language: Option, -} - -// ── Decision engine ───────────────────────────────────────────────────────── - -/// The outcome of a quality decision for a single release. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DownloadDecision { - pub approved: bool, - pub release: ReleaseInfo, - pub rejections: Vec, - /// Custom format score for this release (used for ranking). - pub custom_format_score: i32, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Rejection { - pub reason: String, - pub rejection_type: RejectionType, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum RejectionType { - Permanent, - Temporary, -} - -/// A specification that can accept or reject a release. -pub trait DecisionSpecification: Send + Sync { - fn is_satisfied(&self, context: &DecisionContext) -> Option; -} - -// ── Quality number mapping ────────────────────────────────────────────────── - -/// Maps a `stackarr_parser::Quality` variant to the core model's discriminant -/// number used in quality profile items. -fn parser_quality_to_num(q: stackarr_parser::Quality) -> i32 { - match q { - stackarr_parser::Quality::Unknown => 0, - stackarr_parser::Quality::SDTV => 1, - stackarr_parser::Quality::DVD | stackarr_parser::Quality::DVDRip => 2, - stackarr_parser::Quality::WEBDL480p => 3, - stackarr_parser::Quality::WEBRip480p => 4, - // Bluray480p = 5 in core, but parser doesn't have it - stackarr_parser::Quality::HDTV720p => 6, - stackarr_parser::Quality::WEBDL720p => 7, - stackarr_parser::Quality::WEBRip720p => 8, - stackarr_parser::Quality::Bluray720p => 9, - stackarr_parser::Quality::HDTV1080p => 10, - stackarr_parser::Quality::WEBDL1080p => 11, - stackarr_parser::Quality::WEBRip1080p => 12, - stackarr_parser::Quality::Bluray1080p => 13, - stackarr_parser::Quality::Remux1080p => 14, - stackarr_parser::Quality::HDTV2160p => 15, - stackarr_parser::Quality::WEBDL2160p => 16, - stackarr_parser::Quality::WEBRip2160p => 17, - stackarr_parser::Quality::Bluray2160p => 18, - stackarr_parser::Quality::Remux2160p => 19, - stackarr_parser::Quality::Raw => 20, - } -} - -/// Human-readable name for a quality number. -pub fn quality_name(num: i32) -> &'static str { - match num { - 0 => "Unknown", - 1 => "SDTV", - 2 => "DVD", - 3 => "WEBDL-480p", - 4 => "WEBRip-480p", - 5 => "Bluray-480p", - 6 => "HDTV-720p", - 7 => "WEBDL-720p", - 8 => "WEBRip-720p", - 9 => "Bluray-720p", - 10 => "HDTV-1080p", - 11 => "WEBDL-1080p", - 12 => "WEBRip-1080p", - 13 => "Bluray-1080p", - 14 => "Remux-1080p", - 15 => "HDTV-2160p", - 16 => "WEBDL-2160p", - 17 => "WEBRip-2160p", - 18 => "Bluray-2160p", - 19 => "Remux-2160p", - 20 => "Raw-HD", - _ => "Unknown", - } -} - -/// Parse a release title into its core-model quality number. -fn parse_quality_num(title: &str) -> i32 { - let parsed = stackarr_parser::parse_release(title); - parser_quality_to_num(parsed.quality.quality) -} - -// ── Quality item deserialization ──────────────────────────────────────────── - -#[derive(Debug, Clone, Deserialize)] -struct QualityItem { - /// Quality ID — accepts bare integer `10`, object `{"id": 10, ...}`, or null. - #[serde(default, deserialize_with = "deserialize_quality_id")] - quality: Option, - #[serde(default)] - allowed: bool, - #[serde(default)] - items: Vec, -} - -/// Custom deserializer that handles three formats for the quality field: -/// - Bare integer: `10` → `Some(10)` -/// - Object with id: `{"id": 10, "name": "HDTV-1080p", ...}` → `Some(10)` -/// - null / missing → `None` -fn deserialize_quality_id<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value: Option = Option::deserialize(deserializer)?; - match value { - None | Some(serde_json::Value::Null) => Ok(None), - Some(serde_json::Value::Number(n)) => { - Ok(n.as_i64().and_then(|v| i32::try_from(v).ok())) - } - Some(serde_json::Value::Object(map)) => { - let id = map - .get("id") - .and_then(|v| v.as_i64()) - .and_then(|v| i32::try_from(v).ok()); - Ok(id) - } - Some(_) => Ok(None), - } -} - -impl QualityItem { - /// Flatten nested quality groups into a single list of leaf items. - fn flatten(&self) -> Vec<&QualityItem> { - if self.items.is_empty() { - vec![self] - } else { - // For a group, propagate the group's `allowed` flag to children - // that don't override it. In practice each child carries its own - // allowed flag; the group's flag gates the whole group. - if self.allowed { - self.items.iter().flat_map(|child| child.flatten()).collect() - } else { - // Group disabled — all children effectively disallowed - Vec::new() - } - } - } -} - -fn parse_profile_items(profile: &QualityProfile) -> Vec { - serde_json::from_value(profile.items.clone()).unwrap_or_default() -} - -fn is_quality_allowed(quality_num: i32, profile: &QualityProfile) -> bool { - let items: Vec = parse_profile_items(profile); - let flat: Vec<&QualityItem> = items.iter().flat_map(|i| i.flatten()).collect(); - flat.iter() - .any(|item| item.quality == Some(quality_num) && item.allowed) -} - -// ── Size limits per quality tier ──────────────────────────────────────────── - -/// Returns (min_bytes, max_bytes) for a given quality discriminant number. -fn size_limits(quality: i32) -> (i64, i64) { - match quality { - 1..=5 => (50_000_000, 3_000_000_000), // SD: 50MB - 3GB - 6..=9 => (100_000_000, 8_000_000_000), // 720p: 100MB - 8GB - 10..=14 => (200_000_000, 20_000_000_000), // 1080p: 200MB - 20GB - 15..=19 => (500_000_000, 80_000_000_000), // 2160p: 500MB - 80GB - _ => (0, i64::MAX), - } -} - -// ── Specifications ────────────────────────────────────────────────────────── - -/// Rejects releases whose quality is not allowed in the profile. -pub struct QualityAllowedSpec; - -impl DecisionSpecification for QualityAllowedSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let quality_num = parse_quality_num(&context.release.title); - - if !is_quality_allowed(quality_num, &context.profile) { - Some(Rejection { - reason: format!( - "{} is not allowed in profile '{}'", - quality_name(quality_num), - context.profile.name, - ), - rejection_type: RejectionType::Permanent, - }) - } else { - None - } - } -} - -/// Rejects releases when the cutoff quality has already been met and no -/// upgrade is possible. -pub struct QualityCutoffSpec; - -impl DecisionSpecification for QualityCutoffSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let existing = match context.existing_quality { - Some(q) => q, - None => return None, // No existing file — pass - }; - - let release_quality = parse_quality_num(&context.release.title); - - // If upgrades are disabled, reject any release when we already have a file - if !context.profile.upgrade_allowed { - return Some(Rejection { - reason: "upgrades are disabled for this profile".to_string(), - rejection_type: RejectionType::Permanent, - }); - } - - // If existing quality already meets or exceeds the cutoff, reject - if existing >= context.profile.cutoff { - return Some(Rejection { - reason: format!( - "cutoff already met: existing {} meets cutoff {}", - quality_name(existing), - quality_name(context.profile.cutoff), - ), - rejection_type: RejectionType::Permanent, - }); - } - - // Release must be an upgrade over existing quality - if release_quality <= existing { - return Some(Rejection { - reason: format!( - "{} is not an upgrade over existing {}", - quality_name(release_quality), - quality_name(existing), - ), - rejection_type: RejectionType::Permanent, - }); - } - - None - } -} - -/// Rejects releases below minimum size thresholds. -pub struct MinimumSizeSpec; - -impl DecisionSpecification for MinimumSizeSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let size = context.release.size; - if size <= 0 { - return None; // Size unknown — pass - } - - let quality_num = parse_quality_num(&context.release.title); - let (min, _max) = size_limits(quality_num); - - if size < min { - Some(Rejection { - reason: format!( - "release size {} bytes is below minimum {} bytes for {}", - size, - min, - quality_name(quality_num), - ), - rejection_type: RejectionType::Permanent, - }) - } else { - None - } - } -} - -/// Rejects releases above maximum size thresholds. -pub struct MaximumSizeSpec; - -impl DecisionSpecification for MaximumSizeSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let size = context.release.size; - if size <= 0 { - return None; // Size unknown — pass - } - - let quality_num = parse_quality_num(&context.release.title); - let (_min, max) = size_limits(quality_num); - - if size > max { - Some(Rejection { - reason: format!( - "release size {} bytes exceeds maximum {} bytes for {}", - size, - max, - quality_name(quality_num), - ), - rejection_type: RejectionType::Permanent, - }) - } else { - None - } - } -} - -/// Rejects releases that are in the blocklist. -pub struct BlocklistSpec; - -impl DecisionSpecification for BlocklistSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - if context.in_blocklist { - Some(Rejection { - reason: "release is in the blocklist".to_string(), - rejection_type: RejectionType::Permanent, - }) - } else { - None - } - } -} - -/// Rejects releases when: -/// 1. The exact same release (by guid) is already in the download queue, OR -/// 2. The same media item already has a queued download at equal or higher quality. -pub struct QueueConflictSpec; - -impl DecisionSpecification for QueueConflictSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - // Check 1: exact guid match - if context.in_queue { - return Some(Rejection { - reason: "already in download queue".to_string(), - rejection_type: RejectionType::Temporary, - }); - } - - // Check 2: same media item has a queued download at equal/higher quality - if let Some(queued_q) = context.queued_quality { - let release_q = parse_quality_num(&context.release.title); - if queued_q >= release_q { - let name = quality_name(queued_q); - return Some(Rejection { - reason: format!( - "release in queue is of equal or higher preference: {name}", - ), - rejection_type: RejectionType::Temporary, - }); - } - } - - None - } -} - -/// Rejects releases whose detected language doesn't match the profile language. -/// Mirrors Radarr's language filtering for movie profiles. -pub struct LanguageSpec; - -impl DecisionSpecification for LanguageSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let profile_lang = context.profile.language; - - // -1 = Any language → always pass - if profile_lang == -1 { - return None; - } - - let release_langs = stackarr_parser::parse_languages(&context.release.title); - - // Resolve the wanted language ID - let wanted_id = if profile_lang == -2 { - // -2 = Original → use the media's original language - match context.original_language { - Some(id) => id, - None => return None, // Can't determine original language → pass - } - } else { - profile_lang - }; - - // Convert Radarr language ID to parser Language enum for comparison - let wanted_parser_lang = radarr_id_to_parser_lang(wanted_id); - - // Check if any detected language matches - let matched = release_langs.iter().any(|lang| { - *lang == wanted_parser_lang - || *lang == stackarr_parser::Language::Multi - || *lang == stackarr_parser::Language::Unknown - }); - - if matched { - None - } else { - let found_names: Vec<&str> = release_langs - .iter() - .map(|l| parser_lang_name(*l)) - .collect(); - let wanted_name = parser_lang_name(wanted_parser_lang); - Some(Rejection { - reason: format!( - "{wanted_name} is wanted, but found {}", - found_names.join(", "), - ), - rejection_type: RejectionType::Permanent, - }) - } - } -} - -/// Map Radarr language ID to the parser's Language enum. -fn radarr_id_to_parser_lang(id: i32) -> stackarr_parser::Language { - use stackarr_parser::Language; - match id { - 1 => Language::English, - 2 => Language::French, - 3 => Language::Spanish, - 4 => Language::German, - 5 => Language::Italian, - 6 => Language::Danish, - 7 => Language::Dutch, - 8 => Language::Japanese, - 10 => Language::Chinese, - 11 => Language::Russian, - 12 => Language::Polish, - 14 => Language::Swedish, - 15 => Language::Norwegian, - 16 => Language::Finnish, - 17 => Language::Turkish, - 18 => Language::Portuguese, - 20 => Language::Greek, - 21 => Language::Korean, - 22 => Language::Hungarian, - 23 => Language::Hebrew, - 25 => Language::Czech, - 26 => Language::Hindi, - 27 => Language::Romanian, - 28 => Language::Thai, - 29 => Language::Vietnamese, // Radarr uses 29 for Vietnamese in some versions - _ => Language::Unknown, - } -} - -/// Human-readable name for a parser Language enum value. -fn parser_lang_name(lang: stackarr_parser::Language) -> &'static str { - use stackarr_parser::Language; - match lang { - Language::English => "English", - Language::French => "French", - Language::Spanish => "Spanish", - Language::German => "German", - Language::Italian => "Italian", - Language::Portuguese => "Portuguese", - Language::Japanese => "Japanese", - Language::Chinese => "Chinese", - Language::Korean => "Korean", - Language::Russian => "Russian", - Language::Polish => "Polish", - Language::Dutch => "Dutch", - Language::Swedish => "Swedish", - Language::Norwegian => "Norwegian", - Language::Danish => "Danish", - Language::Finnish => "Finnish", - Language::Turkish => "Turkish", - Language::Arabic => "Arabic", - Language::Hindi => "Hindi", - Language::Czech => "Czech", - Language::Hungarian => "Hungarian", - Language::Romanian => "Romanian", - Language::Greek => "Greek", - Language::Hebrew => "Hebrew", - Language::Thai => "Thai", - Language::Vietnamese => "Vietnamese", - Language::Indonesian => "Indonesian", - Language::Multi => "Multi", - Language::Unknown => "Unknown", - } -} - -/// Rejects torrent releases with insufficient seeders. -pub struct MinimumSeedersSpec; - -impl DecisionSpecification for MinimumSeedersSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - if context.release.protocol != DownloadProtocol::Torrent { - return None; // Only applies to torrents - } - - let seeders = context.release.seeders.unwrap_or(0); - if seeders < 1 { - Some(Rejection { - reason: format!("not enough seeders: {} (minimum is 1)", seeders), - rejection_type: RejectionType::Temporary, - }) - } else { - None - } - } -} - -/// Rejects releases whose custom format score is below the profile minimum. -pub struct CustomFormatScoreSpec; - -impl DecisionSpecification for CustomFormatScoreSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let score = context.release_custom_format_score; - - // Reject if score is below the profile minimum (handles both positive - // minimums and negative scores from LQ custom formats like -10000). - if score < context.profile.min_format_score { - Some(Rejection { - reason: format!( - "custom format score {} is below minimum {} for profile '{}'", - score, context.profile.min_format_score, context.profile.name, - ), - rejection_type: RejectionType::Permanent, - }) - } else { - None - } - } -} - -/// Rejects releases when the existing file's custom format score already meets -/// or exceeds the cutoff format score (mirroring Sonarr/Radarr's -/// "Existing file on disk has equal or higher Custom Format score" rejection). -pub struct CustomFormatCutoffSpec; - -impl DecisionSpecification for CustomFormatCutoffSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - let existing_cf = match context.existing_custom_format_score { - Some(s) => s, - None => return None, // No existing file — pass - }; - - // Only applies when profile has a non-zero cutoff_format_score - if context.profile.cutoff_format_score <= 0 { - return None; - } - - // If existing CF score already meets the cutoff and the release doesn't exceed it - if existing_cf >= context.profile.cutoff_format_score - && context.release_custom_format_score <= existing_cf - { - return Some(Rejection { - reason: format!( - "existing file has equal or higher custom format score: {}", - existing_cf, - ), - rejection_type: RejectionType::Permanent, - }); - } - - None - } -} - -/// Rejects releases that have already been grabbed and imported (by guid). -pub struct AlreadyImportedSpec; - -impl DecisionSpecification for AlreadyImportedSpec { - fn is_satisfied(&self, context: &DecisionContext) -> Option { - if context.already_grabbed { - Some(Rejection { - reason: "release has already been grabbed and imported".to_string(), - rejection_type: RejectionType::Permanent, - }) - } else { - None - } - } -} - -// ── Decision engine ───────────────────────────────────────────────────────── - -/// The decision engine evaluates releases against quality profiles. -pub struct DecisionEngine { - specs: Vec>, -} - -impl DecisionEngine { - /// Create a new engine with the default set of specifications. - pub fn new() -> Self { - let specs: Vec> = vec![ - Box::new(AlreadyImportedSpec), - Box::new(BlocklistSpec), - Box::new(QueueConflictSpec), - Box::new(QualityAllowedSpec), - Box::new(QualityCutoffSpec), - Box::new(CustomFormatCutoffSpec), - Box::new(LanguageSpec), - Box::new(MinimumSizeSpec), - Box::new(MaximumSizeSpec), - Box::new(MinimumSeedersSpec), - Box::new(CustomFormatScoreSpec), - ]; - Self { specs } - } - - /// Decide whether a release should be grabbed. - pub fn decide(&self, context: DecisionContext) -> DownloadDecision { - let mut rejections = Vec::new(); - for spec in &self.specs { - if let Some(r) = spec.is_satisfied(&context) { - rejections.push(r); - } - } - let approved = rejections.is_empty(); - let cf_score = context.release_custom_format_score; - DownloadDecision { - approved, - release: context.release, - rejections, - custom_format_score: cf_score, - } - } -} - -impl Default for DecisionEngine { - fn default() -> Self { - Self::new() - } -} - -// ── Grab Strategy ─────────────────────────────────────────────────────────── - -/// Controls how releases are ranked when multiple results are available. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum GrabStrategy { - /// Rank by quality first, use indexer priority as tiebreaker. - #[default] - BestQuality, - /// Rank by indexer priority first, then quality within same priority. - IndexerPriority, -} - -// ── Release ranking ───────────────────────────────────────────────────────── - -/// Sort approved decisions by preference according to the chosen strategy. -pub fn rank_releases( - mut decisions: Vec, - strategy: GrabStrategy, -) -> Vec { - decisions.sort_by(|a, b| { - // Always: approved first - let cmp = b.approved.cmp(&a.approved); - - match strategy { - GrabStrategy::BestQuality => { - cmp - // Quality first - .then_with(|| { - let qa = parse_quality_num(&a.release.title); - let qb = parse_quality_num(&b.release.title); - qb.cmp(&qa) - }) - // Custom format score (higher is better) - .then_with(|| b.custom_format_score.cmp(&a.custom_format_score)) - // More seeders - .then_with(|| { - let sa = a.release.seeders.unwrap_or(0); - let sb = b.release.seeders.unwrap_or(0); - sb.cmp(&sa) - }) - // Newer first - .then_with(|| a.release.age_days.cmp(&b.release.age_days)) - // Indexer priority as tiebreaker - .then_with(|| a.release.indexer_priority.cmp(&b.release.indexer_priority)) - } - GrabStrategy::IndexerPriority => { - cmp - // Indexer priority first (lower = higher priority) - .then_with(|| a.release.indexer_priority.cmp(&b.release.indexer_priority)) - // Then quality - .then_with(|| { - let qa = parse_quality_num(&a.release.title); - let qb = parse_quality_num(&b.release.title); - qb.cmp(&qa) - }) - // Custom format score (higher is better) - .then_with(|| b.custom_format_score.cmp(&a.custom_format_score)) - // More seeders - .then_with(|| { - let sa = a.release.seeders.unwrap_or(0); - let sb = b.release.seeders.unwrap_or(0); - sb.cmp(&sa) - }) - // Newer first - .then_with(|| a.release.age_days.cmp(&b.release.age_days)) - } - } - }); - decisions -} - -// ── Tests ─────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - - fn make_release(title: &str) -> ReleaseInfo { - ReleaseInfo { - guid: "test-guid".to_string(), - title: title.to_string(), - download_url: Some("http://example.com/dl".to_string()), - info_url: None, - indexer_id: 1, - indexer_name: "TestIndexer".to_string(), - protocol: DownloadProtocol::Usenet, - size: 1_500_000_000, // 1.5GB - age_days: 1, - publish_date: Utc::now(), - info_hash: None, - magnet_url: None, - seeders: None, - leechers: None, - nzb_url: None, - tvdb_id: None, - imdb_id: None, - tmdb_id: None, - categories: vec![], - indexer_flags: vec![], - indexer_priority: 25, - } - } - - fn make_torrent_release(title: &str, seeders: i32) -> ReleaseInfo { - let mut r = make_release(title); - r.protocol = DownloadProtocol::Torrent; - r.seeders = Some(seeders); - r - } - - fn make_profile(items_json: &str) -> QualityProfile { - QualityProfile { - id: 1, - name: "Test Profile".to_string(), - cutoff: 11, // WEBDL-1080p - upgrade_allowed: true, - min_format_score: 0, - cutoff_format_score: 0, - items: serde_json::from_str(items_json).unwrap(), - media_type: None, - language: -1, - } - } - - fn make_context(release: ReleaseInfo, profile: QualityProfile) -> DecisionContext { - DecisionContext { - release, - profile, - existing_quality: None, - existing_custom_format_score: None, - release_custom_format_score: 0, - in_queue: false, - in_blocklist: false, - already_grabbed: false, - queued_quality: None, - original_language: None, - } - } - - // ── QualityAllowedSpec ────────────────────────────────────────────── - - #[test] - fn quality_allowed_passes_when_allowed() { - let spec = QualityAllowedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - // Quality 11 = WEBDL-1080p - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn quality_allowed_rejects_when_not_allowed() { - let spec = QualityAllowedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - // Quality 11 present but not allowed - let profile = make_profile(r#"[{"quality": 11, "allowed": false}]"#); - let ctx = make_context(release, profile); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert_eq!(rejection.unwrap().rejection_type, RejectionType::Permanent); - } - - #[test] - fn quality_allowed_rejects_when_quality_missing_from_profile() { - let spec = QualityAllowedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - // Only 720p qualities in profile, not 1080p - let profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - #[test] - fn quality_allowed_handles_nested_groups() { - let spec = QualityAllowedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile( - r#"[{"quality": null, "allowed": true, "items": [{"quality": 11, "allowed": true, "items": []}]}]"#, - ); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn quality_allowed_disabled_group_rejects_children() { - let spec = QualityAllowedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - // Group is disabled even though child is allowed - let profile = make_profile( - r#"[{"quality": null, "allowed": false, "items": [{"quality": 11, "allowed": true, "items": []}]}]"#, - ); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - // ── QualityCutoffSpec ─────────────────────────────────────────────── - - #[test] - fn cutoff_passes_when_no_existing_file() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn cutoff_passes_when_upgrade_available() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); // quality 11 - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let mut ctx = make_context(release, profile); - ctx.existing_quality = Some(6); // HDTV-720p, below cutoff of 11 - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn cutoff_rejects_when_cutoff_met() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.1080p.BluRay.x264-GROUP"); // quality 13 - let mut profile = make_profile(r#"[{"quality": 13, "allowed": true}]"#); - profile.cutoff = 11; // WEBDL-1080p - let mut ctx = make_context(release, profile); - ctx.existing_quality = Some(11); // Already at cutoff - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("cutoff already met")); - } - - #[test] - fn cutoff_rejects_when_not_an_upgrade() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.720p.HDTV.x264-GROUP"); // quality 6 - let mut profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); - profile.cutoff = 13; // Bluray-1080p - let mut ctx = make_context(release, profile); - ctx.existing_quality = Some(7); // WEBDL-720p, release (6) <= existing (7) - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("not an upgrade")); - } - - #[test] - fn cutoff_rejects_when_upgrades_disabled() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.1080p.BluRay.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 13, "allowed": true}]"#); - profile.upgrade_allowed = false; - let mut ctx = make_context(release, profile); - ctx.existing_quality = Some(6); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("upgrades are disabled")); - } - - // ── MinimumSizeSpec ───────────────────────────────────────────────── - - #[test] - fn minimum_size_passes_within_range() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 500_000_000; // 500MB, within 1080p range - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn minimum_size_rejects_too_small() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 10_000_000; // 10MB, below 200MB minimum for 1080p - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("below minimum")); - } - - #[test] - fn minimum_size_passes_when_size_unknown() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 0; // Unknown size - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - // ── MaximumSizeSpec ───────────────────────────────────────────────── - - #[test] - fn maximum_size_passes_within_range() { - let spec = MaximumSizeSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 5_000_000_000; // 5GB, within 1080p range - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn maximum_size_rejects_too_large() { - let spec = MaximumSizeSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 25_000_000_000; // 25GB, above 20GB max for 1080p - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("exceeds maximum")); - } - - // ── BlocklistSpec ─────────────────────────────────────────────────── - - #[test] - fn blocklist_passes_when_not_blocklisted() { - let spec = BlocklistSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn blocklist_rejects_when_blocklisted() { - let spec = BlocklistSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let mut ctx = make_context(release, profile); - ctx.in_blocklist = true; - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert_eq!(rejection.unwrap().rejection_type, RejectionType::Permanent); - } - - // ── QueueConflictSpec ─────────────────────────────────────────────── - - #[test] - fn queue_passes_when_not_queued() { - let spec = QueueConflictSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn queue_rejects_when_already_queued() { - let spec = QueueConflictSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let mut ctx = make_context(release, profile); - ctx.in_queue = true; - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert_eq!(rejection.unwrap().rejection_type, RejectionType::Temporary); - } - - // ── MinimumSeedersSpec ────────────────────────────────────────────── - - #[test] - fn seeders_passes_for_usenet() { - let spec = MinimumSeedersSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - // Usenet — spec doesn't apply - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn seeders_passes_with_enough_seeders() { - let spec = MinimumSeedersSpec; - let release = make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-GROUP", 5); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn seeders_rejects_zero_seeders_torrent() { - let spec = MinimumSeedersSpec; - let release = make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-GROUP", 0); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert_eq!(rejection.unwrap().rejection_type, RejectionType::Temporary); - } - - #[test] - fn seeders_rejects_no_seeder_info_torrent() { - let spec = MinimumSeedersSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.protocol = DownloadProtocol::Torrent; - release.seeders = None; // No seeder info - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - } - - // ── CustomFormatScoreSpec ─────────────────────────────────────────── - - #[test] - fn custom_format_passes_when_minimum_is_zero() { - let spec = CustomFormatScoreSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn custom_format_rejects_when_below_minimum() { - let spec = CustomFormatScoreSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.min_format_score = 10; - let ctx = make_context(release, profile); - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("below minimum")); - } - - // ── DecisionEngine integration ────────────────────────────────────── - - #[test] - fn engine_approves_good_release() { - let engine = DecisionEngine::new(); - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let decision = engine.decide(ctx); - assert!(decision.approved); - assert!(decision.rejections.is_empty()); - } - - #[test] - fn engine_rejects_disallowed_quality() { - let engine = DecisionEngine::new(); - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); - let ctx = make_context(release, profile); - let decision = engine.decide(ctx); - assert!(!decision.approved); - assert!(!decision.rejections.is_empty()); - } - - #[test] - fn engine_collects_multiple_rejections() { - let engine = DecisionEngine::new(); - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 10_000_000; // Too small - let profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); // Wrong quality - let mut ctx = make_context(release, profile); - ctx.in_blocklist = true; - let decision = engine.decide(ctx); - assert!(!decision.approved); - // Should have at least blocklist + quality + size rejections - assert!(decision.rejections.len() >= 3); - } - - // ── rank_releases ─────────────────────────────────────────────────── - - #[test] - fn rank_approved_before_rejected() { - let approved = DownloadDecision { - approved: true, - release: make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"), - rejections: vec![], - custom_format_score: 0, - }; - let rejected = DownloadDecision { - approved: false, - release: make_release("Show.S01E01.720p.HDTV.x264-GROUP"), - rejections: vec![Rejection { - reason: "not allowed".to_string(), - rejection_type: RejectionType::Permanent, - }], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![rejected, approved], GrabStrategy::BestQuality); - assert!(ranked[0].approved); - assert!(!ranked[1].approved); - } - - #[test] - fn rank_higher_quality_first() { - let r1080 = DownloadDecision { - approved: true, - release: make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"), // quality 11 - rejections: vec![], - custom_format_score: 0, - }; - let r720 = DownloadDecision { - approved: true, - release: make_release("Show.S01E01.720p.HDTV.x264-GROUP"), // quality 6 - rejections: vec![], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![r720, r1080], GrabStrategy::BestQuality); - // 1080p should come first - assert!(ranked[0].release.title.contains("1080p")); - assert!(ranked[1].release.title.contains("720p")); - } - - #[test] - fn rank_more_seeders_first() { - let r_many = DownloadDecision { - approved: true, - release: make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-A", 50), - rejections: vec![], - custom_format_score: 0, - }; - let r_few = DownloadDecision { - approved: true, - release: make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-B", 5), - rejections: vec![], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![r_few, r_many], GrabStrategy::BestQuality); - assert_eq!(ranked[0].release.seeders, Some(50)); - assert_eq!(ranked[1].release.seeders, Some(5)); - } - - #[test] - fn rank_newer_first_when_equal_quality_and_seeders() { - let mut r_new = make_release("Show.S01E01.1080p.WEB-DL.x264-A"); - r_new.age_days = 1; - let mut r_old = make_release("Show.S01E01.1080p.WEB-DL.x264-B"); - r_old.age_days = 10; - - let d_new = DownloadDecision { - approved: true, - release: r_new, - rejections: vec![], - custom_format_score: 0, - }; - let d_old = DownloadDecision { - approved: true, - release: r_old, - rejections: vec![], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![d_old, d_new], GrabStrategy::BestQuality); - assert_eq!(ranked[0].release.age_days, 1); - assert_eq!(ranked[1].release.age_days, 10); - } - - // ── AlreadyImportedSpec ─────────────────────────────────────────── - - #[test] - fn already_imported_passes_when_not_grabbed() { - let spec = AlreadyImportedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn already_imported_rejects_when_already_grabbed() { - let spec = AlreadyImportedSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let mut ctx = make_context(release, profile); - ctx.already_grabbed = true; - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert_eq!(rejection.unwrap().rejection_type, RejectionType::Permanent); - } - - // ── CustomFormatScoreSpec with real score ───────────────────────── - - #[test] - fn custom_format_passes_with_sufficient_score() { - let spec = CustomFormatScoreSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.min_format_score = 5; - let mut ctx = make_context(release, profile); - ctx.release_custom_format_score = 10; - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn custom_format_rejects_with_insufficient_score() { - let spec = CustomFormatScoreSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.min_format_score = 10; - let mut ctx = make_context(release, profile); - ctx.release_custom_format_score = 3; - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("below minimum")); - } - - #[test] - fn custom_format_passes_at_exact_minimum() { - let spec = CustomFormatScoreSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.min_format_score = 10; - let mut ctx = make_context(release, profile); - ctx.release_custom_format_score = 10; - assert!(spec.is_satisfied(&ctx).is_none()); - } - - // ── Size limits per quality tier ────────────────────────────────── - - #[test] - fn minimum_size_sd_within_range() { - let spec = MinimumSizeSpec; - // HDTV without resolution parses as SDTV (quality 1) - let mut release = make_release("Show.S01E01.HDTV.x264-GROUP"); - release.size = 100_000_000; // 100MB, within SD range (50MB-3GB) - let profile = make_profile(r#"[{"quality": 1, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn minimum_size_sd_below_minimum() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.HDTV.x264-GROUP"); - release.size = 10_000_000; // 10MB, below 50MB minimum for SD - let profile = make_profile(r#"[{"quality": 1, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - #[test] - fn maximum_size_sd_exceeds() { - let spec = MaximumSizeSpec; - let mut release = make_release("Show.S01E01.HDTV.x264-GROUP"); - release.size = 5_000_000_000; // 5GB, above 3GB max for SD - let profile = make_profile(r#"[{"quality": 1, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - #[test] - fn minimum_size_720p_within_range() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.720p.HDTV.x264-GROUP"); - release.size = 500_000_000; // 500MB, within 720p range (100MB-8GB) - let profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn minimum_size_720p_below_minimum() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.720p.HDTV.x264-GROUP"); - release.size = 50_000_000; // 50MB, below 100MB minimum for 720p - let profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - #[test] - fn maximum_size_720p_exceeds() { - let spec = MaximumSizeSpec; - let mut release = make_release("Show.S01E01.720p.HDTV.x264-GROUP"); - release.size = 10_000_000_000; // 10GB, above 8GB max for 720p - let profile = make_profile(r#"[{"quality": 6, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - #[test] - fn minimum_size_2160p_within_range() { - let spec = MinimumSizeSpec; - let mut release = make_release("Show.S01E01.2160p.WEB-DL.x265-GROUP"); - release.size = 10_000_000_000; // 10GB, within 2160p range (500MB-80GB) - let profile = make_profile(r#"[{"quality": 16, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn maximum_size_2160p_exceeds() { - let spec = MaximumSizeSpec; - let mut release = make_release("Show.S01E01.2160p.WEB-DL.x265-GROUP"); - release.size = 90_000_000_000; // 90GB, above 80GB max for 2160p - let profile = make_profile(r#"[{"quality": 16, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_some()); - } - - #[test] - fn maximum_size_passes_when_size_unknown() { - let spec = MaximumSizeSpec; - let mut release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - release.size = 0; // Unknown size - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - // ── MinimumSeedersSpec edge cases ───────────────────────────────── - - #[test] - fn seeders_passes_with_exactly_one_seeder() { - let spec = MinimumSeedersSpec; - let release = make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-GROUP", 1); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn seeders_passes_with_many_seeders() { - let spec = MinimumSeedersSpec; - let release = make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-GROUP", 1000); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - // ── QualityCutoffSpec edge cases ────────────────────────────────── - - #[test] - fn cutoff_passes_upgrade_below_cutoff() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); // quality 11 - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.cutoff = 13; // Bluray-1080p - let mut ctx = make_context(release, profile); - ctx.existing_quality = Some(6); // HDTV-720p, below cutoff and below release - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn cutoff_rejects_same_quality_as_existing() { - let spec = QualityCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); // quality 11 - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.cutoff = 13; - let mut ctx = make_context(release, profile); - ctx.existing_quality = Some(11); // Same quality — not an upgrade - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - assert!(rejection.unwrap().reason.contains("not an upgrade")); - } - - // ── DecisionEngine full integration ─────────────────────────────── - - #[test] - fn engine_approves_torrent_with_seeders() { - let engine = DecisionEngine::new(); - let release = make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-GROUP", 10); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let decision = engine.decide(ctx); - assert!(decision.approved); - } - - #[test] - fn engine_rejects_torrent_zero_seeders() { - let engine = DecisionEngine::new(); - let release = make_torrent_release("Show.S01E01.1080p.WEB-DL.x264-GROUP", 0); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let ctx = make_context(release, profile); - let decision = engine.decide(ctx); - assert!(!decision.approved); - assert!(decision.rejections.iter().any(|r| r.reason.contains("seeders"))); - } - - #[test] - fn engine_rejects_already_grabbed() { - let engine = DecisionEngine::new(); - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - let mut ctx = make_context(release, profile); - ctx.already_grabbed = true; - let decision = engine.decide(ctx); - assert!(!decision.approved); - assert!(decision.rejections.iter().any(|r| r.reason.contains("already been grabbed"))); - } - - #[test] - fn engine_rejects_blocklisted_and_wrong_quality() { - let engine = DecisionEngine::new(); - let release = make_release("Show.S01E01.720p.HDTV.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); // Only 1080p allowed - let mut ctx = make_context(release, profile); - ctx.in_blocklist = true; - let decision = engine.decide(ctx); - assert!(!decision.approved); - // Should have both blocklist and quality rejections - assert!(decision.rejections.iter().any(|r| r.reason.contains("blocklist"))); - assert!(decision.rejections.iter().any(|r| r.reason.contains("not allowed"))); - } - - #[test] - fn engine_approves_first_file_no_existing() { - let engine = DecisionEngine::new(); - let release = make_release("Movie.2024.720p.BluRay.x264-GROUP"); - let profile = make_profile(r#"[{"quality": 9, "allowed": true}]"#); - let ctx = make_context(release, profile); - let decision = engine.decide(ctx); - assert!(decision.approved); - } - - // ── rank_releases additional ────────────────────────────────────── - - #[test] - fn rank_2160p_before_1080p() { - let r2160 = DownloadDecision { - approved: true, - release: make_release("Movie.2024.2160p.WEB-DL.x265-GROUP"), - rejections: vec![], - custom_format_score: 0, - }; - let r1080 = DownloadDecision { - approved: true, - release: make_release("Movie.2024.1080p.WEB-DL.x264-GROUP"), - rejections: vec![], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![r1080, r2160], GrabStrategy::BestQuality); - assert!(ranked[0].release.title.contains("2160p")); - assert!(ranked[1].release.title.contains("1080p")); - } - - #[test] - fn rank_empty_list() { - let ranked = rank_releases(vec![], GrabStrategy::BestQuality); - assert!(ranked.is_empty()); - } - - #[test] - fn rank_single_item() { - let d = DownloadDecision { - approved: true, - release: make_release("Movie.2024.1080p.WEB-DL.x264-GROUP"), - rejections: vec![], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![d], GrabStrategy::BestQuality); - assert_eq!(ranked.len(), 1); - assert!(ranked[0].approved); - } - - #[test] - fn rank_multiple_rejected_by_quality() { - let r_2160 = DownloadDecision { - approved: false, - release: make_release("Movie.2024.2160p.WEB-DL.x265-A"), - rejections: vec![Rejection { - reason: "test".to_string(), - rejection_type: RejectionType::Permanent, - }], - custom_format_score: 0, - }; - let r_720 = DownloadDecision { - approved: false, - release: make_release("Movie.2024.720p.HDTV.x264-B"), - rejections: vec![Rejection { - reason: "test".to_string(), - rejection_type: RejectionType::Permanent, - }], - custom_format_score: 0, - }; - let ranked = rank_releases(vec![r_720, r_2160], GrabStrategy::BestQuality); - // Even among rejected, higher quality first - assert!(ranked[0].release.title.contains("2160p")); - assert!(ranked[1].release.title.contains("720p")); - } - - // ── Quality mapping ─────────────────────────────────────────────── - - #[test] - fn quality_name_known_values() { - assert_eq!(quality_name(0), "Unknown"); - assert_eq!(quality_name(1), "SDTV"); - assert_eq!(quality_name(2), "DVD"); - assert_eq!(quality_name(6), "HDTV-720p"); - assert_eq!(quality_name(11), "WEBDL-1080p"); - assert_eq!(quality_name(13), "Bluray-1080p"); - assert_eq!(quality_name(16), "WEBDL-2160p"); - assert_eq!(quality_name(19), "Remux-2160p"); - assert_eq!(quality_name(20), "Raw-HD"); - } - - #[test] - fn quality_name_unknown_returns_unknown() { - assert_eq!(quality_name(99), "Unknown"); - assert_eq!(quality_name(-1), "Unknown"); - } - - #[test] - fn size_limits_sd() { - let (min, max) = size_limits(1); - assert_eq!(min, 50_000_000); - assert_eq!(max, 3_000_000_000); - } - - #[test] - fn size_limits_720p() { - let (min, max) = size_limits(6); - assert_eq!(min, 100_000_000); - assert_eq!(max, 8_000_000_000); - } - - #[test] - fn size_limits_1080p() { - let (min, max) = size_limits(11); - assert_eq!(min, 200_000_000); - assert_eq!(max, 20_000_000_000); - } - - #[test] - fn size_limits_2160p() { - let (min, max) = size_limits(16); - assert_eq!(min, 500_000_000); - assert_eq!(max, 80_000_000_000); - } - - #[test] - fn size_limits_unknown() { - let (min, max) = size_limits(0); - assert_eq!(min, 0); - assert_eq!(max, i64::MAX); - } - - // ── Quality item deserialization ────────────────────────────────── - - #[test] - fn quality_allowed_with_multiple_qualities() { - let profile = make_profile(r#"[ - {"quality": 6, "allowed": true}, - {"quality": 11, "allowed": true}, - {"quality": 13, "allowed": false} - ]"#); - assert!(is_quality_allowed(6, &profile)); - assert!(is_quality_allowed(11, &profile)); - assert!(!is_quality_allowed(13, &profile)); - assert!(!is_quality_allowed(16, &profile)); - } - - #[test] - fn quality_allowed_empty_profile() { - let profile = make_profile(r#"[]"#); - assert!(!is_quality_allowed(11, &profile)); - } - - #[test] - fn quality_allowed_nested_group_enabled() { - let profile = make_profile(r#"[{ - "quality": null, - "allowed": true, - "items": [ - {"quality": 11, "allowed": true, "items": []}, - {"quality": 12, "allowed": true, "items": []} - ] - }]"#); - assert!(is_quality_allowed(11, &profile)); - assert!(is_quality_allowed(12, &profile)); - } - - #[test] - fn quality_allowed_nested_group_disabled() { - let profile = make_profile(r#"[{ - "quality": null, - "allowed": false, - "items": [ - {"quality": 11, "allowed": true, "items": []}, - {"quality": 12, "allowed": true, "items": []} - ] - }]"#); - assert!(!is_quality_allowed(11, &profile)); - assert!(!is_quality_allowed(12, &profile)); - } - - // ── *arr format quality items ────────────────────────────────────── - - #[test] - fn quality_allowed_arr_object_format() { - // Sonarr/Radarr store quality as {"id": N, "name": "..."} - let profile = make_profile(r#"[ - {"quality": {"id": 11, "name": "WEBDL-1080p"}, "allowed": true, "items": []}, - {"quality": {"id": 6, "name": "HDTV-720p"}, "allowed": false, "items": []} - ]"#); - assert!(is_quality_allowed(11, &profile)); - assert!(!is_quality_allowed(6, &profile)); - } - - #[test] - fn quality_allowed_arr_object_with_extra_fields() { - // Sonarr includes source/resolution fields in quality objects - let profile = make_profile(r#"[ - {"quality": {"id": 16, "name": "WEBDL-2160p", "source": "webdl", "resolution": 2160}, "allowed": true, "items": []}, - {"quality": {"id": 11, "name": "WEBDL-1080p", "source": "webdl", "resolution": 1080}, "allowed": false, "items": []} - ]"#); - assert!(is_quality_allowed(16, &profile)); - assert!(!is_quality_allowed(11, &profile)); - } - - #[test] - fn quality_allowed_arr_nested_group_with_objects() { - // Sonarr quality groups: quality=null with nested items using object format - let profile = make_profile(r#"[ - {"quality": null, "name": "WEB 2160p", "id": 1003, "allowed": true, "items": [ - {"quality": {"id": 16, "name": "WEBDL-2160p"}, "allowed": true, "items": []}, - {"quality": {"id": 17, "name": "WEBRip-2160p"}, "allowed": true, "items": []} - ]}, - {"quality": {"id": 11, "name": "WEBDL-1080p"}, "allowed": false, "items": []} - ]"#); - assert!(is_quality_allowed(16, &profile)); - assert!(is_quality_allowed(17, &profile)); - assert!(!is_quality_allowed(11, &profile)); - } - - #[test] - fn quality_allowed_arr_disabled_group_rejects_children() { - let profile = make_profile(r#"[ - {"quality": null, "allowed": false, "items": [ - {"quality": {"id": 16, "name": "WEBDL-2160p"}, "allowed": true, "items": []}, - {"quality": {"id": 17, "name": "WEBRip-2160p"}, "allowed": true, "items": []} - ]} - ]"#); - assert!(!is_quality_allowed(16, &profile)); - assert!(!is_quality_allowed(17, &profile)); - } - - // ── CustomFormatCutoffSpec ─────────────────────────────────────── - - #[test] - fn cf_cutoff_passes_when_no_existing_file() { - let spec = CustomFormatCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.cutoff_format_score = 1000; - let ctx = make_context(release, profile); - assert!(spec.is_satisfied(&ctx).is_none()); - } - - #[test] - fn cf_cutoff_rejects_when_existing_meets_cutoff() { - let spec = CustomFormatCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.cutoff_format_score = 1000; - let mut ctx = make_context(release, profile); - ctx.existing_custom_format_score = Some(1500); - ctx.release_custom_format_score = 400; - let rejection = spec.is_satisfied(&ctx); - assert!(rejection.is_some()); - } - - #[test] - fn cf_cutoff_passes_when_release_exceeds_existing() { - let spec = CustomFormatCutoffSpec; - let release = make_release("Show.S01E01.1080p.WEB-DL.x264-GROUP"); - let mut profile = make_profile(r#"[{"quality": 11, "allowed": true}]"#); - profile.cutoff_format_score = 1000; - let mut ctx = make_context(release, profile); - ctx.existing_custom_format_score = Some(1500); - ctx.release_custom_format_score = 2000; - assert!(spec.is_satisfied(&ctx).is_none()); - } - - // ── parse_quality_num integration ───────────────────────────────── - - #[test] - fn parse_quality_num_webdl_1080p() { - assert_eq!(parse_quality_num("Show.S01E01.1080p.WEB-DL.x264-GROUP"), 11); - } - - #[test] - fn parse_quality_num_bluray_2160p() { - assert_eq!(parse_quality_num("Movie.2024.2160p.BluRay.REMUX.HEVC-GROUP"), 19); - } - - #[test] - fn parse_quality_num_hdtv_720p() { - assert_eq!(parse_quality_num("Show.S01E01.720p.HDTV.x264-GROUP"), 6); - } - - #[test] - fn parse_quality_num_unknown() { - assert_eq!(parse_quality_num("random-non-release-text"), 0); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/Cargo.toml deleted file mode 100644 index 97104fd5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "stackarr-scheduler" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-media = { workspace = true } -stackarr-metadata = { workspace = true } -stackarr-indexer = { workspace = true } -stackarr-download = { workspace = true } -stackarr-import = { workspace = true } -stackarr-quality = { workspace = true } -stackarr-plex = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/src/health.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/src/health.rs deleted file mode 100644 index 0b4a0549..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/src/health.rs +++ /dev/null @@ -1,360 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use sqlx::PgPool; -use tokio::sync::RwLock; - -use stackarr_download::DownloadClientManager; -use stackarr_indexer::IndexerManager; - -/// Maximum consecutive failures before auto-disabling a service. -const FAILURE_THRESHOLD: i32 = 3; - -/// Timeout for each individual health check. -const CHECK_TIMEOUT: Duration = Duration::from_secs(15); - -#[derive(sqlx::FromRow)] -struct DownloadClientRow { - id: i32, - name: String, - client_type: String, - enabled: bool, - auto_disabled: bool, - consecutive_failures: i32, - priority: i32, -} - -#[derive(sqlx::FromRow)] -struct IndexerRow { - id: i64, - name: String, - enabled: bool, - auto_disabled: bool, - consecutive_failures: i32, -} - -/// Run a single pass of health checks against all download clients and indexers. -pub async fn health_check_task( - pool: PgPool, - download_manager: Arc>, - indexer_manager: Arc>, -) -> Result<()> { - check_download_clients(&pool, &download_manager).await; - check_indexers(&pool, &indexer_manager).await; - Ok(()) -} - -async fn check_download_clients( - pool: &PgPool, - download_manager: &Arc>, -) { - let rows: Vec = match sqlx::query_as( - "SELECT id, name, client_type, enabled, auto_disabled, consecutive_failures, priority \ - FROM download_clients \ - WHERE client_type != 'embedded_usenet'", - ) - .fetch_all(pool) - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "health check: failed to query download clients"); - return; - } - }; - - for row in rows { - // Skip user-disabled clients (enabled=false AND auto_disabled=false) - if !row.enabled && !row.auto_disabled { - continue; - } - - let test_result = { - let mgr = download_manager.read().await; - match mgr.client_by_id(row.id as i64) { - Some(client) => { - tokio::time::timeout(CHECK_TIMEOUT, client.test()).await - } - None => { - // Client not in manager — might need rebuilding for auto-disabled - if row.auto_disabled { - try_rebuild_client(pool, download_manager, &row).await; - } - continue; - } - } - }; - - match test_result { - Ok(Ok(())) => { - // Healthy - if row.auto_disabled || row.consecutive_failures > 0 { - if row.auto_disabled { - tracing::info!( - id = row.id, name = %row.name, - "health check: download client recovered, re-enabling" - ); - let mut mgr = download_manager.write().await; - mgr.set_enabled(row.id as i64, true); - } - let _ = sqlx::query( - "UPDATE download_clients \ - SET enabled = true, auto_disabled = false, \ - health_status = 'healthy', consecutive_failures = 0, \ - last_health_check = NOW() \ - WHERE id = $1", - ) - .bind(row.id) - .execute(pool) - .await; - } else { - let _ = sqlx::query( - "UPDATE download_clients \ - SET health_status = 'healthy', last_health_check = NOW() \ - WHERE id = $1", - ) - .bind(row.id) - .execute(pool) - .await; - } - } - Ok(Err(e)) => { - handle_dl_failure(pool, download_manager, &row, &e.to_string()).await; - } - Err(_) => { - handle_dl_failure(pool, download_manager, &row, "connection test timed out").await; - } - } - } -} - -async fn handle_dl_failure( - pool: &PgPool, - download_manager: &Arc>, - row: &DownloadClientRow, - error_msg: &str, -) { - let new_failures = row.consecutive_failures + 1; - - if new_failures >= FAILURE_THRESHOLD && !row.auto_disabled { - tracing::warn!( - id = row.id, name = %row.name, failures = new_failures, - error = error_msg, - "health check: auto-disabling download client after {} consecutive failures", - FAILURE_THRESHOLD - ); - let mut mgr = download_manager.write().await; - mgr.set_enabled(row.id as i64, false); - let _ = sqlx::query( - "UPDATE download_clients \ - SET enabled = false, auto_disabled = true, \ - health_status = 'auto_disabled', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", - ) - .bind(new_failures) - .bind(row.id) - .execute(pool) - .await; - } else { - tracing::debug!( - id = row.id, name = %row.name, failures = new_failures, - error = error_msg, - "health check: download client failed" - ); - let _ = sqlx::query( - "UPDATE download_clients \ - SET health_status = 'unhealthy', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", - ) - .bind(new_failures) - .bind(row.id) - .execute(pool) - .await; - } -} - -async fn try_rebuild_client( - pool: &PgPool, - download_manager: &Arc>, - row: &DownloadClientRow, -) { - // Try to rebuild the client from DB config - let config_row: Option<(serde_json::Value,)> = sqlx::query_as( - "SELECT config FROM download_clients WHERE id = $1", - ) - .bind(row.id) - .fetch_optional(pool) - .await - .unwrap_or(None); - - if let Some((config,)) = config_row { - match stackarr_download::build_from_config(&row.client_type, &config) { - Ok(client) => { - // Test it before adding - match tokio::time::timeout(CHECK_TIMEOUT, client.test()).await { - Ok(Ok(())) => { - tracing::info!( - id = row.id, name = %row.name, - "health check: rebuilt and re-enabled download client" - ); - let mut mgr = download_manager.write().await; - mgr.add_client(row.id as i64, client, row.priority); - let _ = sqlx::query( - "UPDATE download_clients \ - SET enabled = true, auto_disabled = false, \ - health_status = 'healthy', consecutive_failures = 0, \ - last_health_check = NOW() \ - WHERE id = $1", - ) - .bind(row.id) - .execute(pool) - .await; - } - _ => { - tracing::debug!( - id = row.id, name = %row.name, - "health check: rebuilt download client still unhealthy" - ); - } - } - } - Err(e) => { - tracing::debug!( - id = row.id, name = %row.name, error = %e, - "health check: could not rebuild download client" - ); - } - } - } -} - -async fn check_indexers( - pool: &PgPool, - indexer_manager: &Arc>, -) { - let rows: Vec = match sqlx::query_as( - "SELECT id, name, enabled, auto_disabled, consecutive_failures FROM indexers", - ) - .fetch_all(pool) - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "health check: failed to query indexers"); - return; - } - }; - - for row in rows { - // Skip user-disabled indexers - if !row.enabled && !row.auto_disabled { - continue; - } - - let test_result = { - let mgr = indexer_manager.read().await; - match mgr.get_client(row.id) { - Some(client) => { - Some(tokio::time::timeout(CHECK_TIMEOUT, client.caps()).await) - } - None => None, - } - }; - - let test_result = match test_result { - Some(r) => r, - None => continue, // not a Newznab indexer or not registered - }; - - match test_result { - Ok(Ok(_)) => { - // Healthy - if row.auto_disabled || row.consecutive_failures > 0 { - if row.auto_disabled { - tracing::info!( - id = row.id, name = %row.name, - "health check: indexer recovered, re-enabling" - ); - let mut mgr = indexer_manager.write().await; - mgr.set_enabled(row.id, true); - } - let _ = sqlx::query( - "UPDATE indexers \ - SET enabled = true, auto_disabled = false, \ - health_status = 'healthy', consecutive_failures = 0, \ - last_health_check = NOW() \ - WHERE id = $1", - ) - .bind(row.id) - .execute(pool) - .await; - } else { - let _ = sqlx::query( - "UPDATE indexers \ - SET health_status = 'healthy', last_health_check = NOW() \ - WHERE id = $1", - ) - .bind(row.id) - .execute(pool) - .await; - } - } - Ok(Err(e)) => { - handle_indexer_failure(pool, indexer_manager, &row, &e.to_string()).await; - } - Err(_) => { - handle_indexer_failure(pool, indexer_manager, &row, "connection test timed out").await; - } - } - } -} - -async fn handle_indexer_failure( - pool: &PgPool, - indexer_manager: &Arc>, - row: &IndexerRow, - error_msg: &str, -) { - let new_failures = row.consecutive_failures + 1; - - if new_failures >= FAILURE_THRESHOLD && !row.auto_disabled { - tracing::warn!( - id = row.id, name = %row.name, failures = new_failures, - error = error_msg, - "health check: auto-disabling indexer after {} consecutive failures", - FAILURE_THRESHOLD - ); - let mut mgr = indexer_manager.write().await; - mgr.set_enabled(row.id, false); - let _ = sqlx::query( - "UPDATE indexers \ - SET enabled = false, auto_disabled = true, \ - health_status = 'auto_disabled', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", - ) - .bind(new_failures) - .bind(row.id) - .execute(pool) - .await; - } else { - tracing::debug!( - id = row.id, name = %row.name, failures = new_failures, - error = error_msg, - "health check: indexer failed" - ); - let _ = sqlx::query( - "UPDATE indexers \ - SET health_status = 'unhealthy', \ - consecutive_failures = $1, last_health_check = NOW() \ - WHERE id = $2", - ) - .bind(new_failures) - .bind(row.id) - .execute(pool) - .await; - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/src/lib.rs deleted file mode 100644 index 4eaadabe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-scheduler/src/lib.rs +++ /dev/null @@ -1,702 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use anyhow::Result; -use sqlx::PgPool; -use tokio::sync::RwLock; -use tokio::time::interval; - -use stackarr_download::DownloadClientManager; -use stackarr_indexer::IndexerManager; - -pub mod health; - -/// Background scheduler that spawns periodic tasks. -pub struct Scheduler { - pool: PgPool, - rss_interval: Duration, - import_interval: Duration, - refresh_interval: Duration, - import_list_interval: Duration, - plex_recent_interval: Duration, - plex_full_interval: Duration, - plex_watchlist_interval: Duration, - plex_token_interval: Duration, - availability_sync_interval: Duration, - health_check_interval: Duration, - download_manager: Option>>, - indexer_manager: Option>>, -} - -impl Scheduler { - /// Create a scheduler with default intervals. - pub fn new(pool: PgPool) -> Self { - Self { - pool, - rss_interval: Duration::from_secs(15 * 60), // 15 min - import_interval: Duration::from_secs(60), // 1 min - refresh_interval: Duration::from_secs(12 * 3600), // 12 hours - import_list_interval: Duration::from_secs(3600), // 1 hour - plex_recent_interval: Duration::from_secs(5 * 60), // 5 min - plex_full_interval: Duration::from_secs(24 * 3600), // 24 hours - plex_watchlist_interval: Duration::from_secs(3600), // 1 hour - plex_token_interval: Duration::from_secs(12 * 3600), // 12 hours - availability_sync_interval: Duration::from_secs(24 * 3600), // 24 hours - health_check_interval: Duration::from_secs(5 * 60), // 5 min - download_manager: None, - indexer_manager: None, - } - } - - /// Create a scheduler with custom intervals. - pub fn with_intervals( - pool: PgPool, - rss_secs: u64, - import_secs: u64, - refresh_secs: u64, - ) -> Self { - Self { - pool, - rss_interval: Duration::from_secs(rss_secs), - import_interval: Duration::from_secs(import_secs), - refresh_interval: Duration::from_secs(refresh_secs), - import_list_interval: Duration::from_secs(3600), - plex_recent_interval: Duration::from_secs(5 * 60), - plex_full_interval: Duration::from_secs(24 * 3600), - plex_watchlist_interval: Duration::from_secs(3600), - plex_token_interval: Duration::from_secs(12 * 3600), - availability_sync_interval: Duration::from_secs(24 * 3600), - health_check_interval: Duration::from_secs(5 * 60), - download_manager: None, - indexer_manager: None, - } - } - - /// Provide download and indexer managers for health checking. - pub fn with_managers( - mut self, - download_manager: Arc>, - indexer_manager: Arc>, - ) -> Self { - self.download_manager = Some(download_manager); - self.indexer_manager = Some(indexer_manager); - self - } - - /// Start all scheduled tasks. Returns a handle that, when dropped, - /// will stop the scheduler (via the tokio JoinSet going out of scope). - pub async fn start(self) -> Result { - let mut join_set = tokio::task::JoinSet::new(); - let mut task_count = 0; - - // Check which modules are enabled - let enabled = get_enabled_modules(&self.pool).await; - - // Only start core tasks if any module is enabled (skip during first boot) - if !enabled.is_empty() { - // RSS sync task - let rss_dur = self.rss_interval; - join_set.spawn(async move { - let mut tick = interval(rss_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running RSS sync task"); - if let Err(e) = rss_sync_task().await { - tracing::error!(error = %e, "RSS sync task failed"); - } - } - }); - task_count += 1; - - // Import scan task - let import_dur = self.import_interval; - let import_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(import_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running import scan task"); - if let Err(e) = import_scan_task(import_pool.clone()).await { - tracing::error!(error = %e, "import scan task failed"); - } - } - }); - task_count += 1; - - // Metadata refresh task - let refresh_dur = self.refresh_interval; - let refresh_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(refresh_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running metadata refresh task"); - if let Err(e) = metadata_refresh_task(refresh_pool.clone()).await { - tracing::error!(error = %e, "metadata refresh task failed"); - } - } - }); - task_count += 1; - - // Import list sync task - let import_list_dur = self.import_list_interval; - let pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(import_list_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running import list sync task"); - if let Err(e) = import_list_sync_task(pool.clone()).await { - tracing::error!(error = %e, "import list sync task failed"); - } - } - }); - task_count += 1; - - // Scheduled disk scan task (every 12 hours) - let disk_scan_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(Duration::from_secs(12 * 3600)); - loop { - tick.tick().await; - tracing::info!("scheduler: running scheduled disk scan"); - if let Err(e) = scheduled_disk_scan(disk_scan_pool.clone()).await { - tracing::error!(error = %e, "scheduled disk scan failed"); - } - } - }); - task_count += 1; - - // ── Health check task ────────────────────────────────────── - if let (Some(dl_mgr), Some(idx_mgr)) = - (self.download_manager.clone(), self.indexer_manager.clone()) - { - let health_pool = self.pool.clone(); - let health_dur = self.health_check_interval; - join_set.spawn(async move { - // Delay the first health check by 30 seconds to let services start - tokio::time::sleep(Duration::from_secs(30)).await; - let mut tick = interval(health_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running health check"); - if let Err(e) = health::health_check_task( - health_pool.clone(), - dl_mgr.clone(), - idx_mgr.clone(), - ) - .await - { - tracing::error!(error = %e, "health check task failed"); - } - } - }); - task_count += 1; - } - - // ── Plex tasks (only if Plex module is enabled) ───────────── - if enabled.contains(&"plex_integration".to_string()) { - // Plex recently added scan (every 5 min) - let plex_recent_dur = self.plex_recent_interval; - let plex_recent_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(plex_recent_dur); - loop { - tick.tick().await; - tracing::debug!("scheduler: running Plex recent scan"); - let scanner = stackarr_plex::PlexScanner::new(plex_recent_pool.clone()); - if let Err(e) = scanner.recent_scan().await { - tracing::error!(error = %e, "Plex recent scan failed"); - } - } - }); - - // Plex full scan (every 24 hours) - let plex_full_dur = self.plex_full_interval; - let plex_full_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(plex_full_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running Plex full library scan"); - let scanner = stackarr_plex::PlexScanner::new(plex_full_pool.clone()); - if let Err(e) = scanner.full_scan().await { - tracing::error!(error = %e, "Plex full scan failed"); - } - } - }); - - // Plex watchlist sync (every 1 hour) - let plex_wl_dur = self.plex_watchlist_interval; - let plex_wl_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(plex_wl_dur); - loop { - tick.tick().await; - tracing::debug!("scheduler: running Plex watchlist sync"); - let sync = stackarr_plex::WatchlistSync::new(plex_wl_pool.clone()); - if let Err(e) = sync.run().await { - tracing::error!(error = %e, "Plex watchlist sync failed"); - } - } - }); - - // Plex token refresh (every 12 hours) - let plex_token_dur = self.plex_token_interval; - let plex_token_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(plex_token_dur); - loop { - tick.tick().await; - tracing::debug!("scheduler: running Plex token refresh"); - let refresh = stackarr_plex::TokenRefresh::new(plex_token_pool.clone()); - if let Err(e) = refresh.run().await { - tracing::error!(error = %e, "Plex token refresh failed"); - } - } - }); - - // Availability sync (every 24 hours) - let avail_dur = self.availability_sync_interval; - let avail_pool = self.pool.clone(); - join_set.spawn(async move { - let mut tick = interval(avail_dur); - loop { - tick.tick().await; - tracing::info!("scheduler: running availability sync"); - let sync = stackarr_plex::AvailabilitySync::new(avail_pool.clone()); - if let Err(e) = sync.run().await { - tracing::error!(error = %e, "availability sync failed"); - } - } - }); - task_count += 5; - } - } - - if task_count == 0 { - tracing::info!("scheduler: first boot — no tasks started (waiting for setup)"); - } else { - tracing::info!("scheduler started with {} background tasks", task_count); - } - Ok(SchedulerHandle { _join_set: join_set }) - } -} - -/// Handle to the running scheduler. Tasks are cancelled when this is dropped. -pub struct SchedulerHandle { - _join_set: tokio::task::JoinSet<()>, -} - -// ── Module check ──────────────────────────────────────────────────────────── - -async fn get_enabled_modules(pool: &PgPool) -> Vec { - sqlx::query_scalar::<_, String>( - "SELECT module FROM enabled_modules WHERE enabled = true", - ) - .fetch_all(pool) - .await - .unwrap_or_default() -} - -// ── Stub task implementations ─────────────────────────────────────────────── - -async fn rss_sync_task() -> Result<()> { - // TODO: fetch RSS feeds from configured indexers, run through decision - // engine, auto-grab approved releases. - tracing::debug!("RSS sync: no-op stub"); - Ok(()) -} - -async fn import_scan_task(pool: PgPool) -> Result<()> { - // 1. Find all completed downloads in the queue - let completed: Vec<(i64, String, i64, Option, String, String, Option)> = - sqlx::query_as( - "SELECT q.id, q.media_type, q.media_id, q.episode_id, q.download_id, q.title, q.download_client_id \ - FROM queue q WHERE q.status = 'completed'", - ) - .fetch_all(&pool) - .await?; - - if completed.is_empty() { - tracing::debug!("import scan: no completed downloads to process"); - return Ok(()); - } - - tracing::info!("found {} completed downloads to import", completed.len()); - - // 2. Process each completed item - for (queue_id, media_type, media_id, episode_id, download_id, title, client_id) in &completed { - // Look up the download client's output path from its config - let output_path = if let Some(cid) = client_id { - let client_row: Option<(serde_json::Value,)> = sqlx::query_as( - "SELECT config FROM download_clients WHERE id = $1 AND enabled = true", - ) - .bind(cid) - .fetch_optional(&pool) - .await?; - - match client_row { - Some((config,)) => { - // Try to extract the output/completed directory from the client config - let dir = config - .get("output_path") - .or_else(|| config.get("completed_download_handling")) - .or_else(|| config.get("directory")) - .and_then(|v| v.as_str()) - .map(|s| std::path::PathBuf::from(s).join(title)); - dir - } - None => None, - } - } else { - None - }; - - let output_path = match output_path { - Some(p) => p, - None => { - tracing::warn!( - queue_id, - download_id, - "no output path resolved for completed download, skipping" - ); - continue; - } - }; - - if !output_path.exists() { - tracing::warn!( - queue_id, - download_id, - path = %output_path.display(), - "output path does not exist, skipping" - ); - continue; - } - - // Run the import pipeline - let ctx = stackarr_import::ImportContext { - pool: pool.clone(), - download_id: download_id.clone(), - output_path, - media_type: media_type.clone(), - media_id: *media_id, - episode_id: *episode_id, - }; - - match stackarr_import::process_completed_download(ctx).await { - Ok(import_result) => { - if import_result.errors.is_empty() { - tracing::info!( - queue_id, - download_id, - imported = import_result.imported_files.len(), - "import succeeded, removing from queue" - ); - - // Remove from queue on success - sqlx::query("DELETE FROM queue WHERE id = $1") - .bind(queue_id) - .execute(&pool) - .await?; - } else { - tracing::warn!( - queue_id, - download_id, - errors = ?import_result.errors, - "import completed with errors" - ); - - // Mark as warning but leave in queue for retry - sqlx::query( - "UPDATE queue SET error_message = $1 WHERE id = $2", - ) - .bind(import_result.errors.join("; ")) - .bind(queue_id) - .execute(&pool) - .await?; - } - } - Err(e) => { - tracing::error!( - queue_id, - download_id, - error = %e, - "import failed" - ); - - // Update queue with error - sqlx::query( - "UPDATE queue SET status = 'failed', error_message = $1 WHERE id = $2", - ) - .bind(e.to_string()) - .bind(queue_id) - .execute(&pool) - .await?; - } - } - } - - Ok(()) -} - -// ── Real metadata refresh task ────────────────────────────────────────────── - -async fn metadata_refresh_task(pool: PgPool) -> Result<()> { - let refresh_svc = stackarr_media::MetadataRefreshService::new(pool.clone()); - - // 1. Find stale series - let stale_series = refresh_svc.find_stale_series().await?; - if !stale_series.is_empty() { - tracing::info!("refreshing metadata for {} stale series", stale_series.len()); - } - - // 2. For each, try to refresh from TMDB (if TMDB key available) - let tmdb_key = std::env::var("STACKARR_TMDB_API_KEY").ok(); - if let Some(ref key) = tmdb_key { - let tmdb = stackarr_metadata::TmdbClient::new(key.clone()); - - for series_id in stale_series { - let svc = stackarr_media::SeriesService::new(pool.clone()); - if let Ok(series) = svc.get(series_id).await { - if let Some(tmdb_id) = series.tmdb_id { - match tmdb.get_series(tmdb_id).await { - Ok(detail) => { - let _ = refresh_svc - .update_series_metadata( - series_id, - detail.overview.as_deref(), - &detail.status.unwrap_or_default(), - detail.networks.first().map(|n| n.name.as_str()), - detail.episode_run_time.first().copied(), - None, // images — would need TMDB image URL conversion - None, // genres — would need TmdbGenre → String mapping - ) - .await; - } - Err(e) => { - tracing::warn!(series_id, error = %e, "failed to refresh series from TMDB"); - } - } - } - if let Err(e) = refresh_svc.mark_series_synced(series_id).await { - tracing::warn!(series_id, error = %e, "failed to mark series synced"); - } - } - } - } else { - // No TMDB key — just mark them synced so we don't retry every tick - for series_id in stale_series { - let _ = refresh_svc.mark_series_synced(series_id).await; - } - } - - // 3. Same for movies - let stale_movies = refresh_svc.find_stale_movies().await?; - if !stale_movies.is_empty() { - tracing::info!("refreshing metadata for {} stale movies", stale_movies.len()); - } - - if let Some(ref key) = tmdb_key { - let tmdb = stackarr_metadata::TmdbClient::new(key.clone()); - for movie_id in stale_movies { - let svc = stackarr_media::MovieService::new(pool.clone()); - if let Ok(movie) = svc.get(movie_id).await { - if let Some(tmdb_id) = movie.tmdb_id { - match tmdb.get_movie(tmdb_id).await { - Ok(detail) => { - let studio = detail.production_companies.first().map(|c| c.name.as_str()); - let _ = refresh_svc - .update_movie_metadata( - movie_id, - detail.overview.as_deref(), - studio, - None, // images - None, // genres - ) - .await; - } - Err(e) => { - tracing::warn!(movie_id, error = %e, "failed to refresh movie from TMDB"); - } - } - } - if let Err(e) = refresh_svc.mark_movie_synced(movie_id).await { - tracing::warn!(movie_id, error = %e, "failed to mark movie synced"); - } - } - } - } else { - for movie_id in stale_movies { - let _ = refresh_svc.mark_movie_synced(movie_id).await; - } - } - - Ok(()) -} - -// ── Import list sync task ─────────────────────────────────────────────────── - -async fn import_list_sync_task(pool: PgPool) -> Result<()> { - let tmdb_key = std::env::var("STACKARR_TMDB_API_KEY").ok(); - - let tmdb_key = match tmdb_key { - Some(key) if !key.is_empty() => key, - _ => { - // Try loading from DB - match sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'tmdb_api_key'", - ) - .fetch_optional(&pool) - .await - { - Ok(Some(val)) => match val.as_str() { - Some(k) if !k.is_empty() => k.to_string(), - _ => { - tracing::debug!( - "import list sync: no TMDB API key configured, skipping" - ); - return Ok(()); - } - }, - _ => { - tracing::debug!( - "import list sync: no TMDB API key configured, skipping" - ); - return Ok(()); - } - } - } - }; - - let tmdb_client = stackarr_metadata::TmdbClient::new(tmdb_key); - let svc = stackarr_media::import_lists::ImportListService::new(pool); - - match svc.sync_all(&tmdb_client).await { - Ok(results) => { - let total_added: usize = results.iter().map(|r| r.items_added).sum(); - let total_errors: usize = results.iter().map(|r| r.errors.len()).sum(); - if total_added > 0 || total_errors > 0 { - tracing::info!( - lists = results.len(), - added = total_added, - errors = total_errors, - "import list sync completed" - ); - } else { - tracing::debug!("import list sync: nothing new to add"); - } - } - Err(e) => { - tracing::error!(error = %e, "import list sync_all failed"); - } - } - - Ok(()) -} - -// ── Scheduled disk scan task ──────────────────────────────────────────────── - -async fn scheduled_disk_scan(pool: PgPool) -> Result<()> { - let folders: Vec<(String, String)> = sqlx::query_as( - "SELECT path, media_type FROM media_library_folders", - ) - .fetch_all(&pool) - .await?; - - if folders.is_empty() { - tracing::debug!("scheduled disk scan: no media library folders configured"); - return Ok(()); - } - - let mut total_found = 0usize; - let mut total_matched = 0usize; - - for (path, media_type) in &folders { - let scan_path = std::path::Path::new(path); - if !scan_path.exists() { - tracing::warn!(path, "scheduled disk scan: path does not exist, skipping"); - continue; - } - match stackarr_import::disk_scan(&pool, scan_path, media_type).await { - Ok(result) => { - total_found += result.files_found; - total_matched += result.files_matched; - } - Err(e) => { - tracing::error!(path, error = %e, "scheduled disk scan failed for folder"); - } - } - } - - if total_found > 0 { - tracing::info!( - found = total_found, - matched = total_matched, - "scheduled disk scan completed" - ); - } else { - tracing::debug!("scheduled disk scan: no new files found"); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use sqlx::postgres::PgPoolOptions; - - fn dummy_pool() -> PgPool { - // connect_lazy requires a tokio context, so tests must be #[tokio::test] - PgPoolOptions::new() - .max_connections(1) - .connect_lazy("postgresql://fake:fake@localhost:5432/fake") - .expect("lazy pool") - } - - #[tokio::test] - async fn test_default_intervals() { - let sched = Scheduler::new(dummy_pool()); - assert_eq!(sched.rss_interval, Duration::from_secs(15 * 60)); - assert_eq!(sched.import_interval, Duration::from_secs(60)); - assert_eq!(sched.refresh_interval, Duration::from_secs(12 * 3600)); - assert_eq!(sched.import_list_interval, Duration::from_secs(3600)); - assert_eq!(sched.plex_recent_interval, Duration::from_secs(5 * 60)); - assert_eq!(sched.plex_full_interval, Duration::from_secs(24 * 3600)); - assert_eq!(sched.availability_sync_interval, Duration::from_secs(24 * 3600)); - } - - #[tokio::test] - async fn test_custom_intervals() { - let sched = Scheduler::with_intervals(dummy_pool(), 300, 30, 7200); - assert_eq!(sched.rss_interval, Duration::from_secs(300)); - assert_eq!(sched.import_interval, Duration::from_secs(30)); - assert_eq!(sched.refresh_interval, Duration::from_secs(7200)); - // Other intervals remain at defaults - assert_eq!(sched.import_list_interval, Duration::from_secs(3600)); - } - - /// Verify that all media_type values that can appear in the - /// `media_library_folders` table are accepted by `disk_scan`. - /// The scheduler passes media_type strings directly from the DB - /// to `stackarr_import::disk_scan`, so "tv" (from user-created - /// folders) must be accepted alongside "series" and "movie". - #[tokio::test] - async fn test_disk_scan_accepts_all_db_media_types() { - let pool = dummy_pool(); - let dir = tempfile::tempdir().unwrap(); - - // These are the values that can appear in media_library_folders.media_type - for media_type in &["series", "tv", "movie"] { - let result = stackarr_import::disk_scan(&pool, dir.path(), media_type).await; - if let Err(e) = &result { - assert!( - !e.to_string().contains("unknown media_type"), - "media_type '{media_type}' should be accepted by disk_scan, got: {e}" - ); - } - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/Cargo.toml deleted file mode 100644 index 0b59fd39..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "stackarr-stream" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -tokio = { workspace = true } -tokio-util = { workspace = true, features = ["io"] } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -tracing = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -sqlx = { workspace = true } -dashmap = { workspace = true } -bytes = { workspace = true } -mime_guess = { workspace = true } -reqwest = { workspace = true } - -[dev-dependencies] -tempfile = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/direct.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/direct.rs deleted file mode 100644 index 90c1ffec..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/direct.rs +++ /dev/null @@ -1,392 +0,0 @@ -use std::path::Path; - -use tokio::io::{AsyncReadExt, AsyncSeekExt}; -use tokio_util::io::ReaderStream; - -use crate::error::{StreamError, StreamResult}; - -/// Parsed HTTP Range header for a single byte range. -#[derive(Debug, Clone, Copy)] -pub struct ByteRange { - pub start: u64, - pub end: u64, // inclusive -} - -/// Response data for direct file serving. -pub struct DirectPlayResponse { - /// HTTP status code (200 or 206). - pub status: u16, - /// Content-Type header value. - pub content_type: String, - /// Content-Length of the response body. - pub content_length: u64, - /// Content-Range header (only for 206 responses). - pub content_range: Option, - /// Total file size. - pub file_size: u64, - /// Streaming body. - pub body: ReaderStream>, -} - -/// Serve a media file with HTTP range request support. -/// -/// `range_header` should be the raw value of the `Range` HTTP header, if present. -pub async fn serve_file( - file_path: &Path, - range_header: Option<&str>, -) -> StreamResult { - let mut file = tokio::fs::File::open(file_path).await.map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - StreamError::NotFound(file_path.display().to_string()) - } else { - StreamError::Io(e) - } - })?; - - let metadata = file.metadata().await?; - let file_size = metadata.len(); - - let content_type = mime_guess::from_path(file_path) - .first_or_octet_stream() - .to_string(); - - match range_header { - Some(range_str) => { - let range = parse_range(range_str, file_size)?; - - file.seek(std::io::SeekFrom::Start(range.start)).await?; - let length = range.end - range.start + 1; - let take = file.take(length); - - Ok(DirectPlayResponse { - status: 206, - content_type, - content_length: length, - content_range: Some(format!( - "bytes {}-{}/{}", - range.start, range.end, file_size - )), - file_size, - body: ReaderStream::new(take), - }) - } - None => { - let take = file.take(file_size); - Ok(DirectPlayResponse { - status: 200, - content_type, - content_length: file_size, - content_range: None, - file_size, - body: ReaderStream::new(take), - }) - } - } -} - -/// Parse a single-range `Range: bytes=start-end` header. -fn parse_range(header: &str, file_size: u64) -> StreamResult { - let s = header - .strip_prefix("bytes=") - .ok_or_else(|| StreamError::InvalidRange("missing bytes= prefix".into()))?; - - // Only handle a single range (no multi-range) - let range_spec = s - .split(',') - .next() - .ok_or_else(|| StreamError::InvalidRange("empty range".into()))? - .trim(); - - let parts: Vec<&str> = range_spec.splitn(2, '-').collect(); - if parts.len() != 2 { - return Err(StreamError::InvalidRange(format!( - "invalid range format: {range_spec}" - ))); - } - - let (start_str, end_str) = (parts[0].trim(), parts[1].trim()); - - if start_str.is_empty() { - // Suffix range: bytes=-500 means last 500 bytes - let suffix_len: u64 = end_str - .parse() - .map_err(|_| StreamError::InvalidRange(format!("invalid suffix: {end_str}")))?; - if suffix_len == 0 || suffix_len > file_size { - return Err(StreamError::InvalidRange( - "suffix length out of range".into(), - )); - } - Ok(ByteRange { - start: file_size - suffix_len, - end: file_size - 1, - }) - } else { - let start: u64 = start_str - .parse() - .map_err(|_| StreamError::InvalidRange(format!("invalid start: {start_str}")))?; - - let end = if end_str.is_empty() { - // Open-ended range: bytes=500- - file_size - 1 - } else { - let e: u64 = end_str - .parse() - .map_err(|_| StreamError::InvalidRange(format!("invalid end: {end_str}")))?; - e.min(file_size - 1) - }; - - if start > end || start >= file_size { - return Err(StreamError::InvalidRange(format!( - "range {start}-{end} not satisfiable for file size {file_size}" - ))); - } - - Ok(ByteRange { start, end }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Basic range parsing ─────────────────────────────────────────── - - #[test] - fn test_parse_range_full() { - let r = parse_range("bytes=0-999", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 999); - } - - #[test] - fn test_parse_range_open_ended() { - let r = parse_range("bytes=500-", 10000).unwrap(); - assert_eq!(r.start, 500); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_suffix() { - let r = parse_range("bytes=-500", 10000).unwrap(); - assert_eq!(r.start, 9500); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_clamped() { - let r = parse_range("bytes=0-99999", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_invalid() { - assert!(parse_range("bytes=9999-0", 10000).is_err()); - assert!(parse_range("bytes=20000-", 10000).is_err()); - assert!(parse_range("invalid", 10000).is_err()); - } - - // ── Exact boundary ranges ───────────────────────────────────────── - - #[test] - fn test_parse_range_entire_file() { - let r = parse_range("bytes=0-9999", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_first_byte() { - let r = parse_range("bytes=0-0", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 0); - } - - #[test] - fn test_parse_range_last_byte() { - let r = parse_range("bytes=9999-9999", 10000).unwrap(); - assert_eq!(r.start, 9999); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_from_start() { - let r = parse_range("bytes=0-", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 9999); - } - - // ── Suffix ranges ───────────────────────────────────────────────── - - #[test] - fn test_parse_range_suffix_one_byte() { - let r = parse_range("bytes=-1", 10000).unwrap(); - assert_eq!(r.start, 9999); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_suffix_entire_file() { - let r = parse_range("bytes=-10000", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 9999); - } - - #[test] - fn test_parse_range_suffix_zero_is_error() { - assert!(parse_range("bytes=-0", 10000).is_err()); - } - - #[test] - fn test_parse_range_suffix_exceeds_file_size() { - assert!(parse_range("bytes=-10001", 10000).is_err()); - } - - // ── Multi-range (only first is used) ────────────────────────────── - - #[test] - fn test_parse_range_multi_range_uses_first() { - let r = parse_range("bytes=0-499, 500-999", 10000).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 499); - } - - // ── Error cases ─────────────────────────────────────────────────── - - #[test] - fn test_parse_range_no_prefix() { - let err = parse_range("0-999", 10000).unwrap_err(); - match err { - StreamError::InvalidRange(msg) => assert!(msg.contains("missing bytes= prefix")), - other => panic!("expected InvalidRange, got: {other:?}"), - } - } - - #[test] - fn test_parse_range_start_beyond_file() { - assert!(parse_range("bytes=10000-", 10000).is_err()); - } - - #[test] - fn test_parse_range_start_greater_than_end() { - assert!(parse_range("bytes=500-100", 10000).is_err()); - } - - #[test] - fn test_parse_range_non_numeric_start() { - assert!(parse_range("bytes=abc-999", 10000).is_err()); - } - - #[test] - fn test_parse_range_non_numeric_end() { - assert!(parse_range("bytes=0-xyz", 10000).is_err()); - } - - #[test] - fn test_parse_range_non_numeric_suffix() { - assert!(parse_range("bytes=-abc", 10000).is_err()); - } - - // ── Large file ranges ───────────────────────────────────────────── - - #[test] - fn test_parse_range_large_file() { - let file_size: u64 = 50 * 1024 * 1024 * 1024; // 50 GB - let r = parse_range("bytes=0-1048575", file_size).unwrap(); - assert_eq!(r.start, 0); - assert_eq!(r.end, 1048575); // First 1MB - } - - #[test] - fn test_parse_range_large_file_open_ended() { - let file_size: u64 = 50 * 1024 * 1024 * 1024; // 50 GB - let r = parse_range("bytes=49999999999-", file_size).unwrap(); - assert_eq!(r.start, 49_999_999_999); - assert_eq!(r.end, file_size - 1); - } - - // ── serve_file with temp files ──────────────────────────────────── - - #[tokio::test] - async fn test_serve_file_no_range() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("test.mp4"); - tokio::fs::write(&file_path, b"hello world video content").await.unwrap(); - - let resp = serve_file(&file_path, None).await.unwrap(); - assert_eq!(resp.status, 200); - assert_eq!(resp.content_length, 25); - assert_eq!(resp.file_size, 25); - assert!(resp.content_range.is_none()); - } - - #[tokio::test] - async fn test_serve_file_with_range() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("test.mkv"); - tokio::fs::write(&file_path, b"0123456789abcdef").await.unwrap(); - - let resp = serve_file(&file_path, Some("bytes=0-9")).await.unwrap(); - assert_eq!(resp.status, 206); - assert_eq!(resp.content_length, 10); - assert_eq!(resp.file_size, 16); - assert!(resp.content_range.as_ref().unwrap().contains("bytes 0-9/16")); - } - - #[tokio::test] - async fn test_serve_file_with_suffix_range() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("test.avi"); - tokio::fs::write(&file_path, b"0123456789").await.unwrap(); - - let resp = serve_file(&file_path, Some("bytes=-5")).await.unwrap(); - assert_eq!(resp.status, 206); - assert_eq!(resp.content_length, 5); - assert!(resp.content_range.as_ref().unwrap().contains("bytes 5-9/10")); - } - - #[tokio::test] - async fn test_serve_file_not_found() { - let result = serve_file(Path::new("/nonexistent/file.mkv"), None).await; - assert!(result.is_err()); - match result.err().unwrap() { - StreamError::NotFound(_) => {} - other => panic!("expected NotFound, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_serve_file_content_type_mp4() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("movie.mp4"); - tokio::fs::write(&file_path, b"fake").await.unwrap(); - - let resp = serve_file(&file_path, None).await.unwrap(); - assert!(resp.content_type.contains("mp4") || resp.content_type.contains("video")); - } - - #[tokio::test] - async fn test_serve_file_content_type_mkv() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("movie.mkv"); - tokio::fs::write(&file_path, b"fake").await.unwrap(); - - let resp = serve_file(&file_path, None).await.unwrap(); - // mkv may map to x-matroska or octet-stream depending on mime_guess - assert!(!resp.content_type.is_empty()); - } - - #[tokio::test] - async fn test_serve_file_invalid_range() { - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("test.mp4"); - tokio::fs::write(&file_path, b"0123456789").await.unwrap(); - - let result = serve_file(&file_path, Some("bytes=20-30")).await; - assert!(result.is_err()); - match result.err().unwrap() { - StreamError::InvalidRange(_) => {} - other => panic!("expected InvalidRange, got: {other:?}"), - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/error.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/error.rs deleted file mode 100644 index a498acbb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/error.rs +++ /dev/null @@ -1,84 +0,0 @@ -/// Streaming-specific errors. -#[derive(Debug, thiserror::Error)] -pub enum StreamError { - #[error("media file not found: {0}")] - NotFound(String), - #[error("ffprobe error: {0}")] - Probe(String), - #[error("ffmpeg error: {0}")] - Transcode(String), - #[error("session error: {0}")] - Session(String), - #[error("invalid range: {0}")] - InvalidRange(String), - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("database error: {0}")] - Database(#[from] sqlx::Error), - #[error("max concurrent sessions exceeded")] - MaxSessions, - #[error("ffmpeg provisioning error: {0}")] - Provision(String), -} - -pub type StreamResult = Result; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_display_not_found() { - let err = StreamError::NotFound("test.mkv".to_string()); - assert_eq!(err.to_string(), "media file not found: test.mkv"); - } - - #[test] - fn test_error_display_probe() { - let err = StreamError::Probe("ffprobe not found".to_string()); - assert_eq!(err.to_string(), "ffprobe error: ffprobe not found"); - } - - #[test] - fn test_error_display_transcode() { - let err = StreamError::Transcode("ffmpeg crashed".to_string()); - assert_eq!(err.to_string(), "ffmpeg error: ffmpeg crashed"); - } - - #[test] - fn test_error_display_session() { - let err = StreamError::Session("session not found".to_string()); - assert_eq!(err.to_string(), "session error: session not found"); - } - - #[test] - fn test_error_display_invalid_range() { - let err = StreamError::InvalidRange("bad range".to_string()); - assert_eq!(err.to_string(), "invalid range: bad range"); - } - - #[test] - fn test_error_display_max_sessions() { - let err = StreamError::MaxSessions; - assert_eq!(err.to_string(), "max concurrent sessions exceeded"); - } - - #[test] - fn test_error_from_io() { - let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); - let err: StreamError = io_err.into(); - match err { - StreamError::Io(_) => {} - other => panic!("expected Io variant, got: {other:?}"), - } - } - - #[test] - fn test_stream_result_type() { - let ok: StreamResult = Ok(42); - assert_eq!(ok.unwrap(), 42); - - let err: StreamResult = Err(StreamError::NotFound("x".into())); - assert!(err.is_err()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/ffmpeg.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/ffmpeg.rs deleted file mode 100644 index fa0752b1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/ffmpeg.rs +++ /dev/null @@ -1,236 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::Stdio; - -use tokio::process::{Child, Command}; - -use stackarr_core::config::{HwAccelConfig, StreamingConfig}; - -use crate::error::{StreamError, StreamResult}; - -/// Configuration for a single transcode job. -pub struct TranscodeConfig<'a> { - pub source_path: &'a Path, - pub output_dir: &'a Path, - pub video_stream_index: usize, - pub audio_stream_index: usize, - pub subtitle_stream_index: Option, - pub max_width: Option, - pub max_height: Option, - pub video_bitrate: Option, - pub streaming_config: &'a StreamingConfig, -} - -/// A running transcode process. -pub struct TranscodeJob { - pub child: Child, - pub output_dir: PathBuf, - pub playlist_path: PathBuf, -} - -impl TranscodeJob { - /// Kill the transcode process. - pub async fn kill(&mut self) { - if let Err(e) = self.child.kill().await { - tracing::warn!(error = %e, "failed to kill ffmpeg process"); - } - } - - /// Check if the process has exited. - pub fn try_wait(&mut self) -> Option { - self.child.try_wait().ok().flatten() - } -} - -/// Start a transcoding job that outputs HLS segments. -pub async fn start_transcode(config: &TranscodeConfig<'_>) -> StreamResult { - let playlist_path = config.output_dir.join("master.m3u8"); - let segment_pattern = config.output_dir.join("%04d.ts"); - - let mut cmd = Command::new(&config.streaming_config.ffmpeg_path); - - // Hardware acceleration input flags - let hwaccel = &config.streaming_config.hwaccel; - add_hwaccel_input_flags(&mut cmd, hwaccel); - - // Input file - cmd.arg("-i").arg(config.source_path); - - // Stream selection - cmd.arg("-map") - .arg(format!("0:v:{}", config.video_stream_index)); - cmd.arg("-map") - .arg(format!("0:a:{}", config.audio_stream_index)); - - // Video encoding - add_video_encode_flags(&mut cmd, hwaccel, config); - - // Audio encoding — always transcode to AAC for browser compatibility - cmd.args(["-c:a", "aac", "-b:a", "192k", "-ac", "2"]); - - // Subtitle burn-in (if requested and using software encoding or hw download) - if let Some(sub_idx) = config.subtitle_stream_index { - if hwaccel.enabled { - // For QSV/VAAPI: download → burn subs → re-upload - // This is handled in the video filter chain in add_video_encode_flags - tracing::debug!(sub_idx, "subtitle burn-in with hw accel handled in vf chain"); - } else { - cmd.arg("-vf").arg(format!( - "subtitles='{}':si={}", - config.source_path.display(), - sub_idx - )); - } - } - - // HLS output - cmd.args(["-f", "hls"]); - cmd.arg("-hls_time") - .arg(config.streaming_config.segment_duration_secs.to_string()); - cmd.args(["-hls_list_size", "0"]); - cmd.args(["-hls_flags", "independent_segments"]); - cmd.arg("-hls_segment_filename") - .arg(&segment_pattern); - - // Start from the beginning, allow segment generation - cmd.args(["-start_number", "0"]); - - cmd.arg(&playlist_path); - - // Suppress interactive prompts, overwrite output - cmd.arg("-y"); - cmd.args(["-nostdin"]); - - cmd.stdout(Stdio::null()); - cmd.stderr(Stdio::piped()); - - tracing::info!( - source = %config.source_path.display(), - output = %config.output_dir.display(), - hwaccel = hwaccel.enabled, - accel_type = %hwaccel.accel_type, - "starting transcode" - ); - - let child = cmd - .spawn() - .map_err(|e| StreamError::Transcode(format!("failed to spawn ffmpeg: {e}")))?; - - Ok(TranscodeJob { - child, - output_dir: config.output_dir.to_path_buf(), - playlist_path, - }) -} - -fn add_hwaccel_input_flags(cmd: &mut Command, hwaccel: &HwAccelConfig) { - if !hwaccel.enabled { - return; - } - - let device = hwaccel - .device - .as_deref() - .unwrap_or("/dev/dri/renderD128"); - - match hwaccel.accel_type.as_str() { - "qsv" => { - cmd.arg("-init_hw_device") - .arg(format!("qsv=hw,child_device={device}")); - cmd.args(["-filter_hw_device", "hw"]); - cmd.args(["-hwaccel", "qsv"]); - cmd.args(["-hwaccel_output_format", "qsv"]); - } - "vaapi" => { - cmd.arg("-vaapi_device").arg(device); - cmd.args(["-hwaccel", "vaapi"]); - cmd.args(["-hwaccel_output_format", "vaapi"]); - } - "nvenc" => { - cmd.args(["-hwaccel", "cuda"]); - cmd.args(["-hwaccel_output_format", "cuda"]); - } - _ => {} - } -} - -fn add_video_encode_flags(cmd: &mut Command, hwaccel: &HwAccelConfig, config: &TranscodeConfig<'_>) { - // Build scale filter if resolution limits are set - let scale_filter = match (config.max_width, config.max_height) { - (Some(w), Some(h)) => Some(format!("scale='min({w},iw)':min'({h},ih)':force_original_aspect_ratio=decrease")), - (Some(w), None) => Some(format!("scale='min({w},iw)':-2")), - (None, Some(h)) => Some(format!("scale=-2:'min({h},ih)'")), - (None, None) => None, - }; - - if hwaccel.enabled { - match hwaccel.accel_type.as_str() { - "qsv" => { - cmd.args(["-c:v", "h264_qsv"]); - cmd.args(["-preset", "medium"]); - if let Some(bitrate) = config.video_bitrate { - cmd.arg("-b:v").arg(format!("{bitrate}")); - cmd.args(["-maxrate", &format!("{}", bitrate * 12 / 10)]); - cmd.args(["-bufsize", &format!("{}", bitrate * 2)]); - } else { - cmd.args(["-global_quality", "23"]); - } - if let Some(sf) = scale_filter { - // QSV scale: need to download from GPU, scale, re-upload - cmd.arg("-vf") - .arg(format!("hwdownload,format=nv12,{sf},hwupload=extra_hw_frames=64")); - } - } - "vaapi" => { - cmd.args(["-c:v", "h264_vaapi"]); - if let Some(bitrate) = config.video_bitrate { - cmd.arg("-b:v").arg(format!("{bitrate}")); - } else { - cmd.args(["-qp", "23"]); - } - if let Some(sf) = scale_filter { - cmd.arg("-vf") - .arg(format!("hwdownload,format=nv12,{sf},hwupload")); - } else { - cmd.arg("-vf") - .arg("format=nv12|vaapi,hwupload"); - } - } - "nvenc" => { - cmd.args(["-c:v", "h264_nvenc"]); - cmd.args(["-preset", "p4"]); - if let Some(bitrate) = config.video_bitrate { - cmd.arg("-b:v").arg(format!("{bitrate}")); - } else { - cmd.args(["-cq", "23"]); - } - if let Some(sf) = scale_filter { - cmd.arg("-vf").arg(sf); - } - } - _ => { - add_software_encode_flags(cmd, config, &scale_filter); - } - } - } else { - add_software_encode_flags(cmd, config, &scale_filter); - } -} - -fn add_software_encode_flags(cmd: &mut Command, config: &TranscodeConfig<'_>, scale_filter: &Option) { - cmd.args(["-c:v", "libx264"]); - cmd.args(["-preset", "veryfast"]); - if let Some(bitrate) = config.video_bitrate { - cmd.arg("-b:v").arg(format!("{bitrate}")); - cmd.args(["-maxrate", &format!("{}", bitrate * 12 / 10)]); - cmd.args(["-bufsize", &format!("{}", bitrate * 2)]); - } else { - cmd.args(["-crf", "23"]); - } - // Force level 4.1 and yuv420p for maximum browser compatibility - cmd.args(["-level", "4.1"]); - cmd.args(["-pix_fmt", "yuv420p"]); - - if let Some(sf) = scale_filter { - cmd.arg("-vf").arg(sf); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/ffprobe.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/ffprobe.rs deleted file mode 100644 index 49960fe1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/ffprobe.rs +++ /dev/null @@ -1,731 +0,0 @@ -use std::path::Path; - -use crate::error::{StreamError, StreamResult}; -use crate::types::{AudioStream, MediaInfo, SubtitleStream, VideoStream}; - -/// Probe a media file using ffprobe and return structured info. -pub async fn probe(ffprobe_path: &str, file_path: &Path) -> StreamResult { - let output = tokio::process::Command::new(ffprobe_path) - .args([ - "-v", - "quiet", - "-print_format", - "json", - "-show_format", - "-show_streams", - ]) - .arg(file_path) - .output() - .await - .map_err(|e| StreamError::Probe(format!("failed to run ffprobe: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(StreamError::Probe(format!( - "ffprobe exited with {}: {stderr}", - output.status - ))); - } - - let json: serde_json::Value = serde_json::from_slice(&output.stdout) - .map_err(|e| StreamError::Probe(format!("failed to parse ffprobe output: {e}")))?; - - parse_ffprobe_json(&json) -} - -fn parse_ffprobe_json(json: &serde_json::Value) -> StreamResult { - let format = &json["format"]; - let streams = json["streams"] - .as_array() - .ok_or_else(|| StreamError::Probe("no streams array in ffprobe output".into()))?; - - let container = format["format_name"] - .as_str() - .unwrap_or("unknown") - .to_string(); - - let duration_secs = format["duration"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); - - let bitrate = format["bit_rate"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - - let mut video_streams = Vec::new(); - let mut audio_streams = Vec::new(); - let mut subtitle_streams = Vec::new(); - let mut video_idx = 0usize; - let mut audio_idx = 0usize; - let mut sub_idx = 0usize; - - for stream in streams { - let codec_type = stream["codec_type"].as_str().unwrap_or(""); - - match codec_type { - "video" => { - // Skip attached pictures (album art, thumbnails) - let disposition = &stream["disposition"]; - if disposition["attached_pic"].as_i64() == Some(1) { - continue; - } - - let codec = stream["codec_name"].as_str().unwrap_or("").to_string(); - let width = stream["width"].as_u64().unwrap_or(0) as u32; - let height = stream["height"].as_u64().unwrap_or(0) as u32; - let stream_bitrate = stream["bit_rate"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let profile = stream["profile"].as_str().unwrap_or("").to_string(); - let level = stream["level"].as_u64().unwrap_or(0) as u32; - - // HDR detection - let color_transfer = stream["color_transfer"].as_str().unwrap_or(""); - let is_hdr = matches!( - color_transfer, - "smpte2084" | "arib-std-b67" | "smpte428" | "bt2020-10" | "bt2020-12" - ); - - // Frame rate parsing (e.g. "24000/1001" or "24/1") - let frame_rate = stream["r_frame_rate"] - .as_str() - .and_then(parse_frame_rate) - .unwrap_or(0.0); - - video_streams.push(VideoStream { - index: video_idx, - codec, - width, - height, - bitrate: stream_bitrate, - profile, - level, - is_hdr, - frame_rate, - }); - video_idx += 1; - } - "audio" => { - let codec = stream["codec_name"].as_str().unwrap_or("").to_string(); - let channels = stream["channels"].as_u64().unwrap_or(0) as u32; - let tags = &stream["tags"]; - let language = tags["language"].as_str().unwrap_or("und").to_string(); - let title = tags["title"].as_str().unwrap_or("").to_string(); - let stream_bitrate = stream["bit_rate"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let disposition = &stream["disposition"]; - let is_default = disposition["default"].as_i64() == Some(1); - - audio_streams.push(AudioStream { - index: audio_idx, - codec, - channels, - language, - title, - bitrate: stream_bitrate, - is_default, - }); - audio_idx += 1; - } - "subtitle" => { - let codec = stream["codec_name"].as_str().unwrap_or("").to_string(); - let tags = &stream["tags"]; - let language = tags["language"].as_str().unwrap_or("und").to_string(); - let title = tags["title"].as_str().unwrap_or("").to_string(); - let disposition = &stream["disposition"]; - let forced = disposition["forced"].as_i64() == Some(1); - let is_default = disposition["default"].as_i64() == Some(1); - - subtitle_streams.push(SubtitleStream { - index: sub_idx, - codec, - language, - title, - forced, - is_default, - }); - sub_idx += 1; - } - _ => {} - } - } - - Ok(MediaInfo { - container, - duration_secs, - bitrate, - video_streams, - audio_streams, - subtitle_streams, - }) -} - -fn parse_frame_rate(s: &str) -> Option { - let parts: Vec<&str> = s.split('/').collect(); - if parts.len() == 2 { - let num = parts[0].parse::().ok()?; - let den = parts[1].parse::().ok()?; - if den > 0.0 { - return Some(num / den); - } - } - s.parse::().ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Frame rate parsing ──────────────────────────────────────────── - - #[test] - fn test_parse_frame_rate() { - assert!((parse_frame_rate("24000/1001").unwrap() - 23.976).abs() < 0.01); - assert!((parse_frame_rate("24/1").unwrap() - 24.0).abs() < 0.01); - assert!((parse_frame_rate("30").unwrap() - 30.0).abs() < 0.01); - assert!(parse_frame_rate("0/0").is_none()); - } - - #[test] - fn test_parse_frame_rate_ntsc() { - assert!((parse_frame_rate("30000/1001").unwrap() - 29.97).abs() < 0.01); - } - - #[test] - fn test_parse_frame_rate_50fps() { - assert!((parse_frame_rate("50/1").unwrap() - 50.0).abs() < 0.01); - } - - #[test] - fn test_parse_frame_rate_60fps() { - assert!((parse_frame_rate("60000/1001").unwrap() - 59.94).abs() < 0.01); - } - - #[test] - fn test_parse_frame_rate_plain_float() { - assert!((parse_frame_rate("25").unwrap() - 25.0).abs() < 0.01); - } - - #[test] - fn test_parse_frame_rate_invalid() { - assert!(parse_frame_rate("abc").is_none()); - assert!(parse_frame_rate("").is_none()); - assert!(parse_frame_rate("/").is_none()); - } - - #[test] - fn test_parse_frame_rate_zero_denominator() { - assert!(parse_frame_rate("24/0").is_none()); - } - - // ── Basic ffprobe JSON parsing ──────────────────────────────────── - - #[test] - fn test_parse_ffprobe_json() { - let json: serde_json::Value = serde_json::json!({ - "format": { - "format_name": "matroska,webm", - "duration": "2400.123", - "bit_rate": "5000000" - }, - "streams": [ - { - "codec_type": "video", - "codec_name": "h264", - "width": 1920, - "height": 1080, - "bit_rate": "4500000", - "profile": "High", - "level": 41, - "color_transfer": "bt709", - "r_frame_rate": "24000/1001", - "disposition": { "default": 1, "attached_pic": 0 } - }, - { - "codec_type": "audio", - "codec_name": "aac", - "channels": 6, - "bit_rate": "384000", - "tags": { "language": "eng", "title": "Surround" }, - "disposition": { "default": 1 } - }, - { - "codec_type": "subtitle", - "codec_name": "srt", - "tags": { "language": "eng", "title": "English" }, - "disposition": { "default": 0, "forced": 0 } - } - ] - }); - - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.container, "matroska,webm"); - assert!((info.duration_secs - 2400.123).abs() < 0.01); - assert_eq!(info.bitrate, 5_000_000); - assert_eq!(info.video_streams.len(), 1); - assert_eq!(info.video_streams[0].codec, "h264"); - assert_eq!(info.video_streams[0].width, 1920); - assert!(!info.video_streams[0].is_hdr); - assert_eq!(info.audio_streams.len(), 1); - assert_eq!(info.audio_streams[0].channels, 6); - assert_eq!(info.subtitle_streams.len(), 1); - assert_eq!(info.subtitle_streams[0].language, "eng"); - } - - // ── HDR detection ───────────────────────────────────────────────── - - #[test] - fn test_hdr_smpte2084_detected() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "100", "bit_rate": "50000000" }, - "streams": [{ - "codec_type": "video", - "codec_name": "hevc", - "width": 3840, "height": 2160, - "profile": "Main 10", "level": 51, - "color_transfer": "smpte2084", - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(info.video_streams[0].is_hdr); - } - - #[test] - fn test_hdr_hlg_detected() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "100", "bit_rate": "50000000" }, - "streams": [{ - "codec_type": "video", - "codec_name": "hevc", - "width": 3840, "height": 2160, - "profile": "Main 10", "level": 51, - "color_transfer": "arib-std-b67", - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(info.video_streams[0].is_hdr); - } - - #[test] - fn test_hdr_bt2020_10_detected() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "100", "bit_rate": "0" }, - "streams": [{ - "codec_type": "video", - "codec_name": "hevc", - "width": 3840, "height": 2160, - "profile": "Main 10", "level": 51, - "color_transfer": "bt2020-10", - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(info.video_streams[0].is_hdr); - } - - #[test] - fn test_sdr_bt709_not_hdr() { - let json = serde_json::json!({ - "format": { "format_name": "mp4", "duration": "100", "bit_rate": "5000000" }, - "streams": [{ - "codec_type": "video", - "codec_name": "h264", - "width": 1920, "height": 1080, - "profile": "High", "level": 41, - "color_transfer": "bt709", - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(!info.video_streams[0].is_hdr); - } - - #[test] - fn test_missing_color_transfer_not_hdr() { - let json = serde_json::json!({ - "format": { "format_name": "mp4", "duration": "100", "bit_rate": "5000000" }, - "streams": [{ - "codec_type": "video", - "codec_name": "h264", - "width": 1920, "height": 1080, - "profile": "High", "level": 41, - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(!info.video_streams[0].is_hdr); - } - - // ── Attached picture filtering ──────────────────────────────────── - - #[test] - fn test_attached_pic_filtered_out() { - let json = serde_json::json!({ - "format": { "format_name": "mp3", "duration": "300", "bit_rate": "320000" }, - "streams": [ - { - "codec_type": "video", - "codec_name": "mjpeg", - "width": 600, "height": 600, - "profile": "", "level": 0, - "r_frame_rate": "0/0", - "disposition": { "attached_pic": 1, "default": 0 } - }, - { - "codec_type": "audio", - "codec_name": "mp3", - "channels": 2, - "bit_rate": "320000", - "tags": { "language": "und" }, - "disposition": { "default": 1 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(info.video_streams.is_empty(), "attached picture should be filtered out"); - assert_eq!(info.audio_streams.len(), 1); - } - - // ── Multiple streams with correct indexing ──────────────────────── - - #[test] - fn test_multiple_video_streams_indexed_separately() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "7200", "bit_rate": "20000000" }, - "streams": [ - { - "codec_type": "video", - "codec_name": "hevc", - "width": 3840, "height": 2160, - "bit_rate": "18000000", - "profile": "Main 10", "level": 51, - "color_transfer": "smpte2084", - "r_frame_rate": "24000/1001", - "disposition": { "attached_pic": 0 } - }, - { - "codec_type": "video", - "codec_name": "h264", - "width": 1920, "height": 1080, - "bit_rate": "8000000", - "profile": "High", "level": 41, - "color_transfer": "bt709", - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.video_streams.len(), 2); - assert_eq!(info.video_streams[0].index, 0); - assert_eq!(info.video_streams[0].codec, "hevc"); - assert_eq!(info.video_streams[1].index, 1); - assert_eq!(info.video_streams[1].codec, "h264"); - } - - #[test] - fn test_multiple_audio_streams_indexed_separately() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "100", "bit_rate": "5000000" }, - "streams": [ - { - "codec_type": "audio", - "codec_name": "truehd", - "channels": 8, - "bit_rate": "4000000", - "tags": { "language": "eng", "title": "TrueHD 7.1" }, - "disposition": { "default": 1 } - }, - { - "codec_type": "audio", - "codec_name": "aac", - "channels": 2, - "bit_rate": "192000", - "tags": { "language": "eng", "title": "Stereo" }, - "disposition": { "default": 0 } - }, - { - "codec_type": "audio", - "codec_name": "ac3", - "channels": 6, - "bit_rate": "640000", - "tags": { "language": "fra", "title": "French" }, - "disposition": { "default": 0 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.audio_streams.len(), 3); - assert_eq!(info.audio_streams[0].index, 0); - assert_eq!(info.audio_streams[0].codec, "truehd"); - assert!(info.audio_streams[0].is_default); - assert_eq!(info.audio_streams[1].index, 1); - assert!(!info.audio_streams[1].is_default); - assert_eq!(info.audio_streams[2].index, 2); - assert_eq!(info.audio_streams[2].language, "fra"); - } - - #[test] - fn test_subtitle_forced_and_default_flags() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "100", "bit_rate": "5000000" }, - "streams": [ - { - "codec_type": "subtitle", - "codec_name": "srt", - "tags": { "language": "eng", "title": "English" }, - "disposition": { "default": 1, "forced": 0 } - }, - { - "codec_type": "subtitle", - "codec_name": "srt", - "tags": { "language": "eng", "title": "English (Forced)" }, - "disposition": { "default": 0, "forced": 1 } - }, - { - "codec_type": "subtitle", - "codec_name": "hdmv_pgs_subtitle", - "tags": { "language": "jpn", "title": "Japanese PGS" }, - "disposition": { "default": 0, "forced": 0 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.subtitle_streams.len(), 3); - assert!(info.subtitle_streams[0].is_default); - assert!(!info.subtitle_streams[0].forced); - assert!(!info.subtitle_streams[1].is_default); - assert!(info.subtitle_streams[1].forced); - assert_eq!(info.subtitle_streams[2].codec, "hdmv_pgs_subtitle"); - } - - // ── Missing / partial fields ────────────────────────────────────── - - #[test] - fn test_missing_format_fields_use_defaults() { - let json = serde_json::json!({ - "format": {}, - "streams": [{ - "codec_type": "video", - "codec_name": "h264", - "width": 1280, "height": 720, - "r_frame_rate": "30/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.container, "unknown"); - assert_eq!(info.duration_secs, 0.0); - assert_eq!(info.bitrate, 0); - } - - #[test] - fn test_missing_stream_bitrate_defaults_to_zero() { - let json = serde_json::json!({ - "format": { "format_name": "mp4", "duration": "100", "bit_rate": "5000000" }, - "streams": [{ - "codec_type": "video", - "codec_name": "h264", - "width": 1920, "height": 1080, - "profile": "Main", - "level": 40, - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.video_streams[0].bitrate, 0); - } - - #[test] - fn test_missing_audio_tags_default() { - let json = serde_json::json!({ - "format": { "format_name": "avi", "duration": "60", "bit_rate": "1000000" }, - "streams": [{ - "codec_type": "audio", - "codec_name": "mp3", - "channels": 2, - "disposition": { "default": 0 } - }] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.audio_streams[0].language, "und"); - assert_eq!(info.audio_streams[0].title, ""); - } - - #[test] - fn test_unknown_codec_type_ignored() { - let json = serde_json::json!({ - "format": { "format_name": "mkv", "duration": "100", "bit_rate": "5000000" }, - "streams": [ - { - "codec_type": "data", - "codec_name": "bin_data" - }, - { - "codec_type": "video", - "codec_name": "h264", - "width": 1920, "height": 1080, - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.video_streams.len(), 1); - assert!(info.audio_streams.is_empty()); - } - - #[test] - fn test_no_streams_array_returns_error() { - let json = serde_json::json!({ - "format": { "format_name": "mp4", "duration": "100", "bit_rate": "5000000" } - }); - assert!(parse_ffprobe_json(&json).is_err()); - } - - #[test] - fn test_empty_streams_array() { - let json = serde_json::json!({ - "format": { "format_name": "mp4", "duration": "100", "bit_rate": "5000000" }, - "streams": [] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert!(info.video_streams.is_empty()); - assert!(info.audio_streams.is_empty()); - assert!(info.subtitle_streams.is_empty()); - } - - // ── Complex real-world JSON ─────────────────────────────────────── - - #[test] - fn test_full_remux_file() { - let json = serde_json::json!({ - "format": { - "format_name": "matroska,webm", - "duration": "8523.456", - "bit_rate": "35000000" - }, - "streams": [ - { - "codec_type": "video", - "codec_name": "hevc", - "width": 3840, "height": 2160, - "bit_rate": "30000000", - "profile": "Main 10", - "level": 51, - "color_transfer": "smpte2084", - "r_frame_rate": "24000/1001", - "disposition": { "default": 1, "attached_pic": 0 } - }, - { - "codec_type": "audio", - "codec_name": "truehd", - "channels": 8, - "bit_rate": "5000000", - "tags": { "language": "eng", "title": "TrueHD Atmos 7.1" }, - "disposition": { "default": 1 } - }, - { - "codec_type": "audio", - "codec_name": "ac3", - "channels": 6, - "bit_rate": "640000", - "tags": { "language": "eng", "title": "AC3 5.1 Compatibility" }, - "disposition": { "default": 0 } - }, - { - "codec_type": "audio", - "codec_name": "aac", - "channels": 2, - "bit_rate": "192000", - "tags": { "language": "eng", "title": "Stereo Commentary" }, - "disposition": { "default": 0 } - }, - { - "codec_type": "subtitle", - "codec_name": "hdmv_pgs_subtitle", - "tags": { "language": "eng", "title": "English PGS" }, - "disposition": { "default": 1, "forced": 0 } - }, - { - "codec_type": "subtitle", - "codec_name": "hdmv_pgs_subtitle", - "tags": { "language": "eng", "title": "English Forced PGS" }, - "disposition": { "default": 0, "forced": 1 } - }, - { - "codec_type": "subtitle", - "codec_name": "srt", - "tags": { "language": "spa", "title": "Spanish" }, - "disposition": { "default": 0, "forced": 0 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - - assert_eq!(info.container, "matroska,webm"); - assert!((info.duration_secs - 8523.456).abs() < 0.01); - assert_eq!(info.bitrate, 35_000_000); - - assert_eq!(info.video_streams.len(), 1); - assert!(info.video_streams[0].is_hdr); - assert_eq!(info.video_streams[0].width, 3840); - assert_eq!(info.video_streams[0].height, 2160); - assert!((info.video_streams[0].frame_rate - 23.976).abs() < 0.01); - - assert_eq!(info.audio_streams.len(), 3); - assert_eq!(info.audio_streams[0].channels, 8); - assert!(info.audio_streams[0].is_default); - assert_eq!(info.audio_streams[1].channels, 6); - assert_eq!(info.audio_streams[2].channels, 2); - - assert_eq!(info.subtitle_streams.len(), 3); - assert!(info.subtitle_streams[0].is_default); - assert!(info.subtitle_streams[1].forced); - assert_eq!(info.subtitle_streams[2].language, "spa"); - } - - #[test] - fn test_av1_webdl_file() { - let json = serde_json::json!({ - "format": { "format_name": "mp4", "duration": "3600", "bit_rate": "4000000" }, - "streams": [ - { - "codec_type": "video", - "codec_name": "av1", - "width": 1920, "height": 1080, - "bit_rate": "3500000", - "profile": "Main", - "level": 41, - "r_frame_rate": "24/1", - "disposition": { "attached_pic": 0 } - }, - { - "codec_type": "audio", - "codec_name": "opus", - "channels": 6, - "bit_rate": "256000", - "tags": { "language": "eng" }, - "disposition": { "default": 1 } - } - ] - }); - let info = parse_ffprobe_json(&json).unwrap(); - assert_eq!(info.video_streams[0].codec, "av1"); - assert_eq!(info.audio_streams[0].codec, "opus"); - assert!(!info.video_streams[0].is_hdr); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/hls.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/hls.rs deleted file mode 100644 index cef8fffa..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/hls.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::path::Path; -use std::time::Duration; - -use crate::error::{StreamError, StreamResult}; - -/// Read an HLS playlist and rewrite segment URLs to use API routes. -/// -/// `api_prefix` should be like `/api/v1/stream/{media_file_id}/hls/{session_id}`. -pub async fn read_playlist(session_dir: &Path, api_prefix: &str) -> StreamResult { - let playlist_path = session_dir.join("master.m3u8"); - - let content = tokio::fs::read_to_string(&playlist_path).await.map_err(|e| { - StreamError::Transcode(format!( - "failed to read playlist {}: {e}", - playlist_path.display() - )) - })?; - - // Rewrite segment filenames to API URLs - let rewritten = content - .lines() - .map(|line| { - if line.ends_with(".ts") { - format!("{api_prefix}/{line}") - } else { - line.to_string() - } - }) - .collect::>() - .join("\n"); - - Ok(rewritten) -} - -/// Read a single HLS segment file. -/// -/// Validates the segment name to prevent directory traversal. -pub async fn read_segment(session_dir: &Path, segment_name: &str) -> StreamResult> { - // Security: prevent path traversal - if segment_name.contains("..") || segment_name.contains('/') || segment_name.contains('\\') { - return Err(StreamError::NotFound(format!( - "invalid segment name: {segment_name}" - ))); - } - - // Only allow .ts files - if !segment_name.ends_with(".ts") { - return Err(StreamError::NotFound(format!( - "invalid segment type: {segment_name}" - ))); - } - - let segment_path = session_dir.join(segment_name); - tokio::fs::read(&segment_path).await.map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - StreamError::NotFound(format!("segment not found: {segment_name}")) - } else { - StreamError::Io(e) - } - }) -} - -/// Wait for a segment file to appear on disk (ffmpeg writes them incrementally). -/// -/// Returns `Ok(())` if the segment appears within the timeout, or an error if it times out. -pub async fn wait_for_segment( - session_dir: &Path, - segment_name: &str, - timeout: Duration, -) -> StreamResult<()> { - let segment_path = session_dir.join(segment_name); - let deadline = tokio::time::Instant::now() + timeout; - - loop { - if segment_path.exists() { - // Wait a tiny bit more for ffmpeg to finish writing the segment - tokio::time::sleep(Duration::from_millis(50)).await; - return Ok(()); - } - - if tokio::time::Instant::now() >= deadline { - return Err(StreamError::Transcode(format!( - "timed out waiting for segment: {segment_name}" - ))); - } - - tokio::time::sleep(Duration::from_millis(200)).await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - // ── Playlist rewriting ──────────────────────────────────────────── - - #[tokio::test] - async fn test_read_playlist_rewrites_segments() { - let dir = tempfile::tempdir().unwrap(); - let playlist = "#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:6.0,\n0000.ts\n#EXTINF:6.0,\n0001.ts\n#EXT-X-ENDLIST\n"; - fs::write(dir.path().join("master.m3u8"), playlist).unwrap(); - - let result = read_playlist(dir.path(), "/api/v1/stream/42/hls/abc-123").await.unwrap(); - assert!(result.contains("/api/v1/stream/42/hls/abc-123/0000.ts")); - assert!(result.contains("/api/v1/stream/42/hls/abc-123/0001.ts")); - assert!(result.contains("#EXTM3U")); - assert!(result.contains("#EXTINF:6.0,")); - } - - #[tokio::test] - async fn test_read_playlist_preserves_non_segment_lines() { - let dir = tempfile::tempdir().unwrap(); - let playlist = "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:6.006,\n0000.ts\n#EXT-X-ENDLIST"; - fs::write(dir.path().join("master.m3u8"), playlist).unwrap(); - - let result = read_playlist(dir.path(), "/prefix").await.unwrap(); - assert!(result.contains("#EXTM3U")); - assert!(result.contains("#EXT-X-TARGETDURATION:6")); - assert!(result.contains("#EXT-X-MEDIA-SEQUENCE:0")); - assert!(result.contains("#EXT-X-ENDLIST")); - } - - #[tokio::test] - async fn test_read_playlist_empty_file() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("master.m3u8"), "").unwrap(); - - let result = read_playlist(dir.path(), "/prefix").await.unwrap(); - assert_eq!(result, ""); - } - - #[tokio::test] - async fn test_read_playlist_missing_file() { - let dir = tempfile::tempdir().unwrap(); - let result = read_playlist(dir.path(), "/prefix").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_read_playlist_no_segments() { - let dir = tempfile::tempdir().unwrap(); - let playlist = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-ENDLIST"; - fs::write(dir.path().join("master.m3u8"), playlist).unwrap(); - - let result = read_playlist(dir.path(), "/prefix").await.unwrap(); - assert!(!result.contains("/prefix")); - assert!(result.contains("#EXT-X-ENDLIST")); - } - - // ── Segment reading ─────────────────────────────────────────────── - - #[tokio::test] - async fn test_read_segment_success() { - let dir = tempfile::tempdir().unwrap(); - let data = vec![0u8; 1024]; - fs::write(dir.path().join("0000.ts"), &data).unwrap(); - - let result = read_segment(dir.path(), "0000.ts").await.unwrap(); - assert_eq!(result.len(), 1024); - } - - #[tokio::test] - async fn test_read_segment_not_found() { - let dir = tempfile::tempdir().unwrap(); - let err = read_segment(dir.path(), "9999.ts").await.unwrap_err(); - match err { - StreamError::NotFound(msg) => assert!(msg.contains("9999.ts")), - other => panic!("expected NotFound, got: {other:?}"), - } - } - - // ── Path traversal prevention ───────────────────────────────────── - - #[tokio::test] - async fn test_read_segment_rejects_dotdot() { - let dir = tempfile::tempdir().unwrap(); - let err = read_segment(dir.path(), "../../../etc/passwd").await.unwrap_err(); - match err { - StreamError::NotFound(msg) => assert!(msg.contains("invalid segment name")), - other => panic!("expected NotFound for path traversal, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_read_segment_rejects_forward_slash() { - let dir = tempfile::tempdir().unwrap(); - let err = read_segment(dir.path(), "subdir/0000.ts").await.unwrap_err(); - match err { - StreamError::NotFound(msg) => assert!(msg.contains("invalid segment name")), - other => panic!("expected NotFound for slash, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_read_segment_rejects_backslash() { - let dir = tempfile::tempdir().unwrap(); - let err = read_segment(dir.path(), "subdir\\0000.ts").await.unwrap_err(); - match err { - StreamError::NotFound(msg) => assert!(msg.contains("invalid segment name")), - other => panic!("expected NotFound for backslash, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_read_segment_rejects_non_ts_extension() { - let dir = tempfile::tempdir().unwrap(); - let err = read_segment(dir.path(), "0000.m3u8").await.unwrap_err(); - match err { - StreamError::NotFound(msg) => assert!(msg.contains("invalid segment type")), - other => panic!("expected NotFound for non-ts, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_read_segment_rejects_no_extension() { - let dir = tempfile::tempdir().unwrap(); - let err = read_segment(dir.path(), "segment_data").await.unwrap_err(); - match err { - StreamError::NotFound(msg) => assert!(msg.contains("invalid segment type")), - other => panic!("expected NotFound for no extension, got: {other:?}"), - } - } - - // ── wait_for_segment ────────────────────────────────────────────── - - #[tokio::test] - async fn test_wait_for_segment_already_exists() { - let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("0000.ts"), b"data").unwrap(); - - let result = wait_for_segment(dir.path(), "0000.ts", Duration::from_secs(1)).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn test_wait_for_segment_timeout() { - let dir = tempfile::tempdir().unwrap(); - // Don't create the segment file - let result = wait_for_segment(dir.path(), "9999.ts", Duration::from_millis(300)).await; - assert!(result.is_err()); - match result.unwrap_err() { - StreamError::Transcode(msg) => assert!(msg.contains("timed out")), - other => panic!("expected Transcode timeout, got: {other:?}"), - } - } - - #[tokio::test] - async fn test_wait_for_segment_appears_during_wait() { - let dir = tempfile::tempdir().unwrap(); - let dir_path = dir.path().to_path_buf(); - - // Spawn a task that creates the segment after a short delay - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(100)).await; - fs::write(dir_path.join("0001.ts"), b"segment data").unwrap(); - }); - - let result = wait_for_segment(dir.path(), "0001.ts", Duration::from_secs(5)).await; - assert!(result.is_ok()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/lib.rs deleted file mode 100644 index ce06c0ba..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod direct; -pub mod error; -pub mod ffmpeg; -pub mod ffprobe; -pub mod hls; -pub mod provision; -pub mod session; -pub mod subtitle; -pub mod types; - -pub use error::{StreamError, StreamResult}; -pub use provision::{ensure_ffmpeg, FfmpegPaths}; -pub use session::SessionManager; -pub use types::*; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/provision.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/provision.rs deleted file mode 100644 index 6a89da80..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/provision.rs +++ /dev/null @@ -1,330 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::error::{StreamError, StreamResult}; - -/// Resolved absolute paths to ffmpeg and ffprobe binaries. -pub struct FfmpegPaths { - pub ffmpeg: String, - pub ffprobe: String, -} - -/// Ensure ffmpeg and ffprobe are available, downloading static builds if necessary. -/// -/// Strategy: -/// 1. If the configured paths are executable, use them (Docker / system install / user override). -/// 2. If binaries exist in `{data_dir}/ffmpeg/`, use those (previous download). -/// 3. Download platform-specific static builds to `{data_dir}/ffmpeg/`. -pub async fn ensure_ffmpeg( - configured_ffmpeg: &str, - configured_ffprobe: &str, - data_dir: &Path, -) -> StreamResult { - // Step 1: Check configured paths - if is_executable(configured_ffmpeg).await && is_executable(configured_ffprobe).await { - tracing::info!( - ffmpeg = configured_ffmpeg, - ffprobe = configured_ffprobe, - "using system ffmpeg" - ); - return Ok(FfmpegPaths { - ffmpeg: configured_ffmpeg.to_string(), - ffprobe: configured_ffprobe.to_string(), - }); - } - - // Step 2: Check data_dir/ffmpeg/ - let local_dir = data_dir.join("ffmpeg"); - let (ffmpeg_bin, ffprobe_bin) = binary_names(); - let local_ffmpeg = local_dir.join(ffmpeg_bin); - let local_ffprobe = local_dir.join(ffprobe_bin); - - let local_ffmpeg_str = local_ffmpeg.to_string_lossy().to_string(); - let local_ffprobe_str = local_ffprobe.to_string_lossy().to_string(); - - if is_executable(&local_ffmpeg_str).await && is_executable(&local_ffprobe_str).await { - tracing::info!(path = %local_dir.display(), "using previously downloaded ffmpeg"); - return Ok(FfmpegPaths { - ffmpeg: local_ffmpeg_str, - ffprobe: local_ffprobe_str, - }); - } - - // Step 3: Download - tracing::info!("ffmpeg/ffprobe not found — downloading static build"); - let url = download_url()?; - - tokio::fs::create_dir_all(&local_dir) - .await - .map_err(|e| StreamError::Provision(format!("failed to create {}: {e}", local_dir.display())))?; - - download_and_extract(url, &local_dir).await?; - - // Verify the downloaded binaries work - if !is_executable(&local_ffmpeg_str).await { - return Err(StreamError::Provision(format!( - "downloaded ffmpeg at {} is not executable", - local_ffmpeg.display() - ))); - } - if !is_executable(&local_ffprobe_str).await { - return Err(StreamError::Provision(format!( - "downloaded ffprobe at {} is not executable", - local_ffprobe.display() - ))); - } - - tracing::info!(path = %local_dir.display(), "ffmpeg provisioned successfully"); - Ok(FfmpegPaths { - ffmpeg: local_ffmpeg_str, - ffprobe: local_ffprobe_str, - }) -} - -/// Check if a binary is executable by running `{path} -version` with a timeout. -async fn is_executable(path: &str) -> bool { - let result = tokio::time::timeout( - std::time::Duration::from_secs(5), - tokio::process::Command::new(path) - .arg("-version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(), - ) - .await; - - matches!(result, Ok(Ok(status)) if status.success()) -} - -/// Platform-specific binary names. -fn binary_names() -> (&'static str, &'static str) { - if cfg!(target_os = "windows") { - ("ffmpeg.exe", "ffprobe.exe") - } else { - ("ffmpeg", "ffprobe") - } -} - -/// Platform-specific download URL for static ffmpeg builds. -fn download_url() -> StreamResult<&'static str> { - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - { - Ok("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz") - } - - #[cfg(all(target_os = "linux", target_arch = "aarch64"))] - { - Ok("https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz") - } - - #[cfg(all(target_os = "windows", target_arch = "x86_64"))] - { - Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip") - } - - #[cfg(not(any( - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - all(target_os = "windows", target_arch = "x86_64"), - )))] - { - Err(StreamError::Provision( - "automatic ffmpeg download is not supported on this platform — please install ffmpeg manually".into(), - )) - } -} - -/// Download the archive from `url` and extract ffmpeg/ffprobe binaries into `target_dir`. -async fn download_and_extract(url: &str, target_dir: &Path) -> StreamResult<()> { - let archive_path = target_dir.join("ffmpeg-download.tmp"); - - // Stream download to temp file - tracing::info!(%url, "downloading ffmpeg"); - let response = reqwest::get(url) - .await - .map_err(|e| StreamError::Provision(format!("download failed: {e}")))?; - - if !response.status().is_success() { - return Err(StreamError::Provision(format!( - "download returned HTTP {}", - response.status() - ))); - } - - let bytes = response - .bytes() - .await - .map_err(|e| StreamError::Provision(format!("failed to read download: {e}")))?; - - let size_mb = bytes.len() / (1024 * 1024); - tracing::info!(size_mb, "download complete, extracting"); - - tokio::fs::write(&archive_path, &bytes) - .await - .map_err(|e| StreamError::Provision(format!("failed to write archive: {e}")))?; - - // Extract platform-specific - if cfg!(target_os = "windows") { - extract_zip(&archive_path, target_dir).await?; - } else { - extract_tar_xz(&archive_path, target_dir).await?; - } - - // Clean up archive - let _ = tokio::fs::remove_file(&archive_path).await; - - Ok(()) -} - -/// Extract a tar.xz archive on Linux, pulling out just ffmpeg and ffprobe to target_dir. -async fn extract_tar_xz(archive: &Path, target_dir: &Path) -> StreamResult<()> { - // Use system tar — universally available on Linux and handles xz natively - let status = tokio::process::Command::new("tar") - .args(["xf", &archive.to_string_lossy()]) - .arg("--strip-components=2") - .arg("--wildcards") - .args(["*/bin/ffmpeg", "*/bin/ffprobe"]) - .arg("-C") - .arg(target_dir) - .status() - .await - .map_err(|e| StreamError::Provision(format!("failed to run tar: {e}")))?; - - if !status.success() { - return Err(StreamError::Provision(format!( - "tar extraction failed with exit code: {}", - status.code().unwrap_or(-1) - ))); - } - - // Ensure execute permissions - let (ffmpeg_bin, ffprobe_bin) = binary_names(); - for bin in [ffmpeg_bin, ffprobe_bin] { - let path = target_dir.join(bin); - if path.exists() { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)); - } - } - } - - Ok(()) -} - -/// Extract a zip archive on Windows, pulling out just ffmpeg.exe and ffprobe.exe to target_dir. -async fn extract_zip(archive: &Path, target_dir: &Path) -> StreamResult<()> { - // Use PowerShell Expand-Archive, then find and move the binaries - let extract_tmp = target_dir.join("_extract_tmp"); - - let status = tokio::process::Command::new("powershell") - .args([ - "-NoProfile", - "-Command", - &format!( - "Expand-Archive -Path '{}' -DestinationPath '{}' -Force", - archive.to_string_lossy(), - extract_tmp.to_string_lossy() - ), - ]) - .status() - .await - .map_err(|e| StreamError::Provision(format!("failed to run powershell: {e}")))?; - - if !status.success() { - return Err(StreamError::Provision("zip extraction failed".into())); - } - - // Find and move ffmpeg.exe and ffprobe.exe from nested directories - let (ffmpeg_bin, ffprobe_bin) = binary_names(); - for bin_name in [ffmpeg_bin, ffprobe_bin] { - let found = find_file_recursive(&extract_tmp, bin_name).await; - match found { - Some(src) => { - let dest = target_dir.join(bin_name); - // Try rename first (fast, same filesystem), fall back to copy - if tokio::fs::rename(&src, &dest).await.is_err() { - tokio::fs::copy(&src, &dest) - .await - .map_err(|e| { - StreamError::Provision(format!("failed to move {bin_name}: {e}")) - })?; - } - } - None => { - return Err(StreamError::Provision(format!( - "{bin_name} not found in extracted archive" - ))); - } - } - } - - // Clean up extraction temp - let _ = tokio::fs::remove_dir_all(&extract_tmp).await; - - Ok(()) -} - -/// Recursively find a file by name in a directory. -async fn find_file_recursive(dir: &Path, name: &str) -> Option { - let mut stack = vec![dir.to_path_buf()]; - while let Some(current) = stack.pop() { - let mut entries = match tokio::fs::read_dir(¤t).await { - Ok(e) => e, - Err(_) => continue, - }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - if path.is_dir() { - stack.push(path); - } else if path.file_name().and_then(|n| n.to_str()) == Some(name) { - return Some(path); - } - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_is_executable_with_known_binary() { - // "echo" or "true" should be available on all unix systems - if cfg!(unix) { - assert!(is_executable("true").await); - } - } - - #[tokio::test] - async fn test_is_executable_nonexistent() { - assert!(!is_executable("nonexistent_binary_xyz_12345").await); - } - - #[test] - fn test_binary_names() { - let (ffmpeg, ffprobe) = binary_names(); - assert!(ffmpeg.starts_with("ffmpeg")); - assert!(ffprobe.starts_with("ffprobe")); - } - - #[test] - fn test_download_url_returns_ok() { - // Should succeed on CI/dev machines (linux x86_64 or windows x86_64) - if cfg!(any( - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "linux", target_arch = "aarch64"), - all(target_os = "windows", target_arch = "x86_64"), - )) { - let url = download_url().unwrap(); - assert!(url.starts_with("https://")); - } - } - - #[test] - fn test_provision_error_display() { - let err = StreamError::Provision("test error".to_string()); - assert_eq!(err.to_string(), "ffmpeg provisioning error: test error"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/session.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/session.rs deleted file mode 100644 index f73b8f5c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/session.rs +++ /dev/null @@ -1,276 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use chrono::{DateTime, Utc}; -use dashmap::DashMap; -use sqlx::PgPool; -use uuid::Uuid; - -use stackarr_core::config::StreamingConfig; - -use crate::error::{StreamError, StreamResult}; -use crate::ffmpeg::{self, TranscodeConfig, TranscodeJob}; -use crate::types::{SessionInfo, TranscodeRequest, TranscodeResponse}; - -/// Internal session state. -struct Session { - id: Uuid, - media_file_id: i64, - session_type: SessionType, - transcode_job: Option, - transcode_dir: Option, - started_at: DateTime, - last_activity: DateTime, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SessionType { - Direct, - Transcode, -} - -impl std::fmt::Display for SessionType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Direct => write!(f, "direct"), - Self::Transcode => write!(f, "transcode"), - } - } -} - -/// Manages active streaming sessions, transcode processes, and cleanup. -pub struct SessionManager { - sessions: DashMap, - config: StreamingConfig, - pool: PgPool, -} - -impl SessionManager { - pub fn new(config: StreamingConfig, pool: PgPool) -> Self { - Self { - sessions: DashMap::new(), - config, - pool, - } - } - - /// The configured transcode directory. - pub fn transcode_dir(&self) -> PathBuf { - self.config - .transcode_dir - .clone() - .unwrap_or_else(|| PathBuf::from("/config/transcode")) - } - - /// Access the streaming config. - pub fn config(&self) -> &StreamingConfig { - &self.config - } - - /// Record a direct-play session. - pub async fn create_direct_session(&self, media_file_id: i64) -> StreamResult { - let id = Uuid::new_v4(); - let now = Utc::now(); - - // Record in DB - sqlx::query( - "INSERT INTO streaming_sessions (id, media_file_id, session_type, status, started_at, last_activity) - VALUES ($1, $2, 'direct', 'active', $3, $3)", - ) - .bind(id) - .bind(media_file_id) - .bind(now) - .execute(&self.pool) - .await?; - - self.sessions.insert( - id, - Session { - id, - media_file_id, - session_type: SessionType::Direct, - transcode_job: None, - transcode_dir: None, - started_at: now, - last_activity: now, - }, - ); - - Ok(id) - } - - /// Start a new transcode session. Returns the session ID and HLS playlist URL. - pub async fn create_transcode_session( - &self, - media_file_id: i64, - source_path: &std::path::Path, - request: &TranscodeRequest, - ) -> StreamResult { - // Check max concurrent sessions - let active_transcodes = self - .sessions - .iter() - .filter(|s| s.value().session_type == SessionType::Transcode) - .count(); - - if active_transcodes >= self.config.max_concurrent_sessions { - return Err(StreamError::MaxSessions); - } - - let session_id = Uuid::new_v4(); - let session_dir = self.transcode_dir().join(session_id.to_string()); - tokio::fs::create_dir_all(&session_dir).await?; - - let transcode_config = TranscodeConfig { - source_path, - output_dir: &session_dir, - video_stream_index: request.video_stream_index, - audio_stream_index: request.audio_stream_index, - subtitle_stream_index: request.subtitle_stream_index, - max_width: request.max_width, - max_height: request.max_height, - video_bitrate: request.video_bitrate, - streaming_config: &self.config, - }; - - let job = ffmpeg::start_transcode(&transcode_config).await?; - - let now = Utc::now(); - let playlist_url = format!( - "/api/v1/stream/{media_file_id}/hls/{session_id}/master.m3u8" - ); - - // Record in DB - sqlx::query( - "INSERT INTO streaming_sessions (id, media_file_id, session_type, status, started_at, last_activity, transcode_dir) - VALUES ($1, $2, 'transcode', 'active', $3, $3, $4)", - ) - .bind(session_id) - .bind(media_file_id) - .bind(now) - .bind(session_dir.to_string_lossy().as_ref()) - .execute(&self.pool) - .await?; - - self.sessions.insert( - session_id, - Session { - id: session_id, - media_file_id, - session_type: SessionType::Transcode, - transcode_job: Some(job), - transcode_dir: Some(session_dir), - started_at: now, - last_activity: now, - }, - ); - - Ok(TranscodeResponse { - session_id, - playlist_url, - }) - } - - /// Get the transcode directory for a session (for serving HLS). - pub fn get_session_dir(&self, session_id: Uuid) -> Option { - self.sessions - .get(&session_id) - .and_then(|s| s.transcode_dir.clone()) - } - - /// Verify a session exists and belongs to the specified media file. - pub fn validate_session(&self, session_id: Uuid, media_file_id: i64) -> bool { - self.sessions - .get(&session_id) - .is_some_and(|s| s.media_file_id == media_file_id) - } - - /// Update last activity timestamp for a session. - pub fn heartbeat(&self, session_id: Uuid) { - if let Some(mut s) = self.sessions.get_mut(&session_id) { - s.last_activity = Utc::now(); - } - } - - /// List all active sessions. - pub fn list_sessions(&self) -> Vec { - self.sessions - .iter() - .map(|entry| { - let s = entry.value(); - SessionInfo { - session_id: s.id, - media_file_id: s.media_file_id, - session_type: s.session_type.to_string(), - status: "active".to_string(), - started_at: s.started_at.to_rfc3339(), - last_activity: s.last_activity.to_rfc3339(), - transcode_progress: None, - video_codec: None, - audio_codec: None, - resolution: None, - } - }) - .collect() - } - - /// Stop a session: kill ffmpeg, clean up temp dir, remove from DB. - pub async fn stop_session(&self, session_id: Uuid) -> StreamResult<()> { - if let Some((_, mut session)) = self.sessions.remove(&session_id) { - // Kill transcode process if running - if let Some(ref mut job) = session.transcode_job { - job.kill().await; - } - - // Clean up temp directory - if let Some(ref dir) = session.transcode_dir { - if dir.exists() { - if let Err(e) = tokio::fs::remove_dir_all(dir).await { - tracing::warn!(dir = %dir.display(), error = %e, "failed to clean up transcode dir"); - } - } - } - - // Update DB - let _ = sqlx::query( - "UPDATE streaming_sessions SET status = 'completed' WHERE id = $1", - ) - .bind(session_id) - .execute(&self.pool) - .await; - } - - Ok(()) - } - - /// Spawn a background task that cleans up idle sessions. - pub fn spawn_cleanup_task(self: &Arc) { - let mgr = Arc::clone(self); - tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); - loop { - interval.tick().await; - mgr.cleanup_idle_sessions().await; - } - }); - } - - async fn cleanup_idle_sessions(&self) { - let idle_threshold = Utc::now() - chrono::Duration::minutes(5); - let mut to_remove = Vec::new(); - - for entry in self.sessions.iter() { - if entry.value().last_activity < idle_threshold { - to_remove.push(*entry.key()); - } - } - - for session_id in to_remove { - tracing::info!(%session_id, "cleaning up idle streaming session"); - if let Err(e) = self.stop_session(session_id).await { - tracing::warn!(%session_id, error = %e, "failed to stop idle session"); - } - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/subtitle.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/subtitle.rs deleted file mode 100644 index 09764983..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/subtitle.rs +++ /dev/null @@ -1,124 +0,0 @@ -use std::path::Path; - -use crate::error::{StreamError, StreamResult}; - -/// Extract an embedded subtitle track to WebVTT format using ffmpeg. -/// -/// `track_index` is the subtitle stream index (0-based among subtitle streams). -pub async fn extract_to_webvtt( - ffmpeg_path: &str, - source: &Path, - track_index: usize, - output: &Path, -) -> StreamResult<()> { - let output = tokio::process::Command::new(ffmpeg_path) - .args(["-nostdin", "-y"]) - .arg("-i") - .arg(source) - .arg("-map") - .arg(format!("0:s:{track_index}")) - .args(["-f", "webvtt"]) - .arg(output) - .output() - .await - .map_err(|e| StreamError::Transcode(format!("failed to run ffmpeg for subtitle extraction: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(StreamError::Transcode(format!( - "subtitle extraction failed: {stderr}" - ))); - } - - Ok(()) -} - -/// Check if a subtitle codec is a bitmap format that cannot be converted to text. -/// -/// Bitmap subtitles (PGS, DVB, VOBSUB) must be burned into the video stream -/// rather than served as a separate text track. -pub fn is_bitmap_subtitle(codec: &str) -> bool { - matches!( - codec, - "hdmv_pgs_subtitle" | "pgssub" | "dvb_subtitle" | "dvdsub" | "dvd_subtitle" | "xsub" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Bitmap subtitle detection ───────────────────────────────────── - - #[test] - fn test_pgs_is_bitmap() { - assert!(is_bitmap_subtitle("hdmv_pgs_subtitle")); - } - - #[test] - fn test_pgssub_is_bitmap() { - assert!(is_bitmap_subtitle("pgssub")); - } - - #[test] - fn test_dvb_subtitle_is_bitmap() { - assert!(is_bitmap_subtitle("dvb_subtitle")); - } - - #[test] - fn test_dvdsub_is_bitmap() { - assert!(is_bitmap_subtitle("dvdsub")); - } - - #[test] - fn test_dvd_subtitle_is_bitmap() { - assert!(is_bitmap_subtitle("dvd_subtitle")); - } - - #[test] - fn test_xsub_is_bitmap() { - assert!(is_bitmap_subtitle("xsub")); - } - - // ── Text subtitles are NOT bitmap ───────────────────────────────── - - #[test] - fn test_srt_is_not_bitmap() { - assert!(!is_bitmap_subtitle("srt")); - } - - #[test] - fn test_subrip_is_not_bitmap() { - assert!(!is_bitmap_subtitle("subrip")); - } - - #[test] - fn test_ass_is_not_bitmap() { - assert!(!is_bitmap_subtitle("ass")); - } - - #[test] - fn test_ssa_is_not_bitmap() { - assert!(!is_bitmap_subtitle("ssa")); - } - - #[test] - fn test_webvtt_is_not_bitmap() { - assert!(!is_bitmap_subtitle("webvtt")); - } - - #[test] - fn test_mov_text_is_not_bitmap() { - assert!(!is_bitmap_subtitle("mov_text")); - } - - #[test] - fn test_empty_string_is_not_bitmap() { - assert!(!is_bitmap_subtitle("")); - } - - #[test] - fn test_unknown_codec_is_not_bitmap() { - assert!(!is_bitmap_subtitle("unknown_codec")); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/types.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/types.rs deleted file mode 100644 index 26c79473..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-stream/src/types.rs +++ /dev/null @@ -1,394 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Complete media information extracted by ffprobe. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MediaInfo { - pub container: String, - pub duration_secs: f64, - pub bitrate: u64, - pub video_streams: Vec, - pub audio_streams: Vec, - pub subtitle_streams: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VideoStream { - pub index: usize, - pub codec: String, - pub width: u32, - pub height: u32, - pub bitrate: u64, - pub profile: String, - pub level: u32, - pub is_hdr: bool, - pub frame_rate: f64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AudioStream { - pub index: usize, - pub codec: String, - pub channels: u32, - pub language: String, - pub title: String, - pub bitrate: u64, - pub is_default: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SubtitleStream { - pub index: usize, - pub codec: String, - pub language: String, - pub title: String, - pub forced: bool, - pub is_default: bool, -} - -/// Transcode request from the client. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TranscodeRequest { - #[serde(default)] - pub video_stream_index: usize, - #[serde(default)] - pub audio_stream_index: usize, - pub subtitle_stream_index: Option, - pub max_width: Option, - pub max_height: Option, - pub video_bitrate: Option, -} - -/// Session info returned to clients. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionInfo { - pub session_id: uuid::Uuid, - pub media_file_id: i64, - pub session_type: String, - pub status: String, - pub started_at: String, - pub last_activity: String, - pub transcode_progress: Option, - pub video_codec: Option, - pub audio_codec: Option, - pub resolution: Option, -} - -/// Response when a transcode session is created. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TranscodeResponse { - pub session_id: uuid::Uuid, - pub playlist_url: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── MediaInfo serde roundtrip ───────────────────────────────────── - - #[test] - fn media_info_serde_roundtrip() { - let info = MediaInfo { - container: "matroska,webm".to_string(), - duration_secs: 5400.5, - bitrate: 8_000_000, - video_streams: vec![VideoStream { - index: 0, - codec: "hevc".to_string(), - width: 3840, - height: 2160, - bitrate: 7_000_000, - profile: "Main 10".to_string(), - level: 51, - is_hdr: true, - frame_rate: 23.976, - }], - audio_streams: vec![AudioStream { - index: 0, - codec: "truehd".to_string(), - channels: 8, - language: "eng".to_string(), - title: "TrueHD Atmos 7.1".to_string(), - bitrate: 4_000_000, - is_default: true, - }], - subtitle_streams: vec![SubtitleStream { - index: 0, - codec: "ass".to_string(), - language: "jpn".to_string(), - title: "Full Subtitles".to_string(), - forced: false, - is_default: false, - }], - }; - - let json = serde_json::to_string(&info).unwrap(); - let deserialized: MediaInfo = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.container, "matroska,webm"); - assert!((deserialized.duration_secs - 5400.5).abs() < 0.01); - assert_eq!(deserialized.bitrate, 8_000_000); - assert_eq!(deserialized.video_streams.len(), 1); - assert_eq!(deserialized.video_streams[0].codec, "hevc"); - assert!(deserialized.video_streams[0].is_hdr); - assert_eq!(deserialized.audio_streams[0].channels, 8); - assert_eq!(deserialized.subtitle_streams[0].language, "jpn"); - } - - #[test] - fn media_info_camel_case_keys() { - let info = MediaInfo { - container: "mp4".to_string(), - duration_secs: 120.0, - bitrate: 5000, - video_streams: vec![VideoStream { - index: 0, - codec: "h264".to_string(), - width: 1920, - height: 1080, - bitrate: 4500, - profile: "High".to_string(), - level: 41, - is_hdr: false, - frame_rate: 24.0, - }], - audio_streams: vec![], - subtitle_streams: vec![], - }; - - let json = serde_json::to_string(&info).unwrap(); - assert!(json.contains("\"durationSecs\"")); - assert!(json.contains("\"videoStreams\"")); - assert!(json.contains("\"audioStreams\"")); - assert!(json.contains("\"subtitleStreams\"")); - assert!(json.contains("\"isHdr\"")); - assert!(json.contains("\"frameRate\"")); - assert!(json.contains("\"isDefault\"") == false); // no audio streams - } - - #[test] - fn media_info_empty_streams() { - let info = MediaInfo { - container: "avi".to_string(), - duration_secs: 0.0, - bitrate: 0, - video_streams: vec![], - audio_streams: vec![], - subtitle_streams: vec![], - }; - - let json = serde_json::to_string(&info).unwrap(); - let deserialized: MediaInfo = serde_json::from_str(&json).unwrap(); - assert!(deserialized.video_streams.is_empty()); - assert!(deserialized.audio_streams.is_empty()); - assert!(deserialized.subtitle_streams.is_empty()); - } - - #[test] - fn media_info_multiple_streams() { - let info = MediaInfo { - container: "mkv".to_string(), - duration_secs: 7200.0, - bitrate: 20_000_000, - video_streams: vec![ - VideoStream { - index: 0, - codec: "hevc".to_string(), - width: 3840, - height: 2160, - bitrate: 18_000_000, - profile: "Main 10".to_string(), - level: 51, - is_hdr: true, - frame_rate: 23.976, - }, - VideoStream { - index: 1, - codec: "h264".to_string(), - width: 1920, - height: 1080, - bitrate: 8_000_000, - profile: "High".to_string(), - level: 41, - is_hdr: false, - frame_rate: 24.0, - }, - ], - audio_streams: vec![ - AudioStream { - index: 0, - codec: "truehd".to_string(), - channels: 8, - language: "eng".to_string(), - title: "TrueHD 7.1".to_string(), - bitrate: 4_000_000, - is_default: true, - }, - AudioStream { - index: 1, - codec: "aac".to_string(), - channels: 2, - language: "eng".to_string(), - title: "Stereo".to_string(), - bitrate: 192_000, - is_default: false, - }, - AudioStream { - index: 2, - codec: "ac3".to_string(), - channels: 6, - language: "fra".to_string(), - title: "French 5.1".to_string(), - bitrate: 640_000, - is_default: false, - }, - ], - subtitle_streams: vec![ - SubtitleStream { - index: 0, - codec: "srt".to_string(), - language: "eng".to_string(), - title: "English".to_string(), - forced: false, - is_default: true, - }, - SubtitleStream { - index: 1, - codec: "srt".to_string(), - language: "eng".to_string(), - title: "English (Forced)".to_string(), - forced: true, - is_default: false, - }, - ], - }; - - let json = serde_json::to_string(&info).unwrap(); - let de: MediaInfo = serde_json::from_str(&json).unwrap(); - assert_eq!(de.video_streams.len(), 2); - assert_eq!(de.audio_streams.len(), 3); - assert_eq!(de.subtitle_streams.len(), 2); - assert_eq!(de.video_streams[1].codec, "h264"); - assert_eq!(de.audio_streams[2].language, "fra"); - assert!(de.subtitle_streams[1].forced); - } - - // ── TranscodeRequest deserialization ─────────────────────────────── - - #[test] - fn transcode_request_defaults() { - let json = r#"{}"#; - let req: TranscodeRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.video_stream_index, 0); - assert_eq!(req.audio_stream_index, 0); - assert!(req.subtitle_stream_index.is_none()); - assert!(req.max_width.is_none()); - assert!(req.max_height.is_none()); - assert!(req.video_bitrate.is_none()); - } - - #[test] - fn transcode_request_with_all_fields() { - let json = r#"{ - "videoStreamIndex": 1, - "audioStreamIndex": 2, - "subtitleStreamIndex": 0, - "maxWidth": 1920, - "maxHeight": 1080, - "videoBitrate": 5000000 - }"#; - let req: TranscodeRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.video_stream_index, 1); - assert_eq!(req.audio_stream_index, 2); - assert_eq!(req.subtitle_stream_index, Some(0)); - assert_eq!(req.max_width, Some(1920)); - assert_eq!(req.max_height, Some(1080)); - assert_eq!(req.video_bitrate, Some(5_000_000)); - } - - #[test] - fn transcode_request_partial_fields() { - let json = r#"{"maxWidth": 1280}"#; - let req: TranscodeRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.video_stream_index, 0); - assert_eq!(req.audio_stream_index, 0); - assert_eq!(req.max_width, Some(1280)); - assert!(req.max_height.is_none()); - } - - // ── SessionInfo serialization ───────────────────────────────────── - - #[test] - fn session_info_serialize_camel_case() { - let info = SessionInfo { - session_id: uuid::Uuid::nil(), - media_file_id: 42, - session_type: "transcode".to_string(), - status: "active".to_string(), - started_at: "2024-01-01T00:00:00Z".to_string(), - last_activity: "2024-01-01T00:05:00Z".to_string(), - transcode_progress: Some(0.5), - video_codec: Some("h264".to_string()), - audio_codec: Some("aac".to_string()), - resolution: Some("1920x1080".to_string()), - }; - - let json = serde_json::to_string(&info).unwrap(); - assert!(json.contains("\"sessionId\"")); - assert!(json.contains("\"mediaFileId\"")); - assert!(json.contains("\"sessionType\"")); - assert!(json.contains("\"startedAt\"")); - assert!(json.contains("\"lastActivity\"")); - assert!(json.contains("\"transcodeProgress\"")); - assert!(json.contains("\"videoCodec\"")); - assert!(json.contains("\"audioCodec\"")); - } - - #[test] - fn session_info_null_optionals() { - let info = SessionInfo { - session_id: uuid::Uuid::nil(), - media_file_id: 1, - session_type: "direct".to_string(), - status: "active".to_string(), - started_at: "2024-01-01T00:00:00Z".to_string(), - last_activity: "2024-01-01T00:00:00Z".to_string(), - transcode_progress: None, - video_codec: None, - audio_codec: None, - resolution: None, - }; - - let json = serde_json::to_string(&info).unwrap(); - let val: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!(val["transcodeProgress"].is_null()); - assert!(val["videoCodec"].is_null()); - assert!(val["audioCodec"].is_null()); - assert!(val["resolution"].is_null()); - } - - // ── TranscodeResponse serialization ─────────────────────────────── - - #[test] - fn transcode_response_serialize() { - let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); - let resp = TranscodeResponse { - session_id: id, - playlist_url: "/api/v1/stream/1/hls/550e8400-e29b-41d4-a716-446655440000/master.m3u8" - .to_string(), - }; - - let json = serde_json::to_string(&resp).unwrap(); - assert!(json.contains("\"sessionId\"")); - assert!(json.contains("\"playlistUrl\"")); - assert!(json.contains("550e8400")); - assert!(json.contains("master.m3u8")); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/Cargo.toml deleted file mode 100644 index c42b60f9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/Cargo.toml +++ /dev/null @@ -1,57 +0,0 @@ -[package] -name = "stackarr-web" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -stackarr-core = { workspace = true } -stackarr-media = { workspace = true } -stackarr-quality = { workspace = true } -stackarr-indexer = { workspace = true } -stackarr-download = { workspace = true } -stackarr-import = { workspace = true } -stackarr-scheduler = { workspace = true } -stackarr-notify = { workspace = true } -stackarr-metadata = { workspace = true } -stackarr-migrate = { workspace = true } -stackarr-plex = { workspace = true } -stackarr-cardigann = { workspace = true } -stackarr-stream = { workspace = true } -serde_yaml = { workspace = true } -axum = { workspace = true, features = ["multipart"] } -tower = { workspace = true } -tower-http = { workspace = true } -utoipa = { workspace = true } -utoipa-swagger-ui = { workspace = true } -sqlx = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -chrono = { workspace = true } -uuid = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -arc-swap = { workspace = true } -tracing = { workspace = true } -tokio = { workspace = true } -tokio-util = { workspace = true, features = ["io"] } -bytes = { workspace = true } -tempfile = "3" -librtbit = { workspace = true } -nzb-web = { workspace = true } -nzb-core = { workspace = true } -nzb-nntp = { workspace = true } -reqwest = { workspace = true } -sha2 = { workspace = true } -url = { workspace = true } -serde_urlencoded = { workspace = true } -governor = { workspace = true } -dashmap = { workspace = true } - -[dev-dependencies] -stackarr-core = { workspace = true, features = ["testing"] } -http-body-util = { workspace = true } -uuid = { workspace = true } diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/lib.rs deleted file mode 100644 index e20911af..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/lib.rs +++ /dev/null @@ -1,365 +0,0 @@ -pub mod middleware; -pub mod routes; -pub mod state; - -pub use state::AppState; - -use std::sync::Arc; - -use axum::http::{HeaderName, HeaderValue, Method}; -use axum::Router; -use tower_http::cors::CorsLayer; -use tower_http::services::{ServeDir, ServeFile}; -use tower_http::set_header::SetResponseHeaderLayer; -use tower_http::trace::TraceLayer; - -/// Build the full API router with all routes mounted. -pub fn build_router(state: Arc) -> Router { - // ── Public routes (no auth required) ───────────────────────────── - let public_routes = Router::new() - .merge(routes::health::router()) - .merge(routes::system::public_router()) - .merge(routes::images::router()) - .merge(routes::auth::router()); - - // ── Protected routes (require API key) ─────────────────────────── - let protected_routes = Router::new() - .merge(routes::system::protected_router()) - .merge(routes::series::router()) - .merge(routes::movies::router()) - .merge(routes::queue::router()) - .merge(routes::history::router()) - .merge(routes::releases::router()) - .merge(routes::quality::router()) - .merge(routes::medialibraryfolders::router()) - .merge(routes::tags::router()) - .merge(routes::naming::router()) - .merge(routes::downloadclients::router()) - .merge(routes::indexers::router()) - .merge(routes::calendar::router()) - .merge(routes::wanted::router()) - .merge(routes::episodes::router()) - .merge(routes::torrent::router()) - .merge(routes::usenet::router()) - .merge(routes::importlists::router()) - .merge(routes::indexarr::router()) - .merge(routes::discover::router()) - .merge(routes::plex::router()) - .merge(routes::blocklist::router()) - .merge(routes::backup::router()) - .merge(routes::logs::router()) - .merge(routes::stream::router()) - .merge(routes::remote::router()) - .merge(routes::search::router()) - .merge(routes::general::router()) - .merge(routes::admin::router()) - .merge(routes::user::router()) - .merge(routes::progress::router()) - .merge(routes::requests::router()) - .merge(routes::watchlist::router()) - .merge(routes::notifications::router()) - .merge(routes::bootstrap::router()); - - // ── CORS configuration ─────────────────────────────────────────── - let cors = CorsLayer::new() - .allow_methods([ - Method::GET, - Method::POST, - Method::PUT, - Method::DELETE, - Method::OPTIONS, - ]) - .allow_headers([ - axum::http::header::CONTENT_TYPE, - axum::http::header::AUTHORIZATION, - HeaderName::from_static("x-api-key"), - HeaderName::from_static("x-csrf-token"), - ]) - .allow_origin(tower_http::cors::AllowOrigin::mirror_request()) - .allow_credentials(true); - - // ── Security headers ───────────────────────────────────────────── - let api_router = Router::new() - .merge(public_routes) - .merge(protected_routes) - .layer(TraceLayer::new_for_http()) - .layer(cors) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("x-frame-options"), - HeaderValue::from_static("DENY"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("x-content-type-options"), - HeaderValue::from_static("nosniff"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("x-xss-protection"), - HeaderValue::from_static("1; mode=block"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("referrer-policy"), - HeaderValue::from_static("strict-origin-when-cross-origin"), - )) - .layer(SetResponseHeaderLayer::overriding( - HeaderName::from_static("permissions-policy"), - HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), - )) - .with_state(state); - - // Serve client app at /app with SPA fallback - let client_dir = std::env::var("STACKARR_CLIENT_DIR").unwrap_or_else(|_| "/client".to_string()); - let client_fallback = ServeFile::new(format!("{client_dir}/index.html")); - let client_serve = ServeDir::new(&client_dir).fallback(client_fallback); - - // Serve UI static files with SPA fallback - let ui_dir = std::env::var("STACKARR_UI_DIR").unwrap_or_else(|_| "/ui".to_string()); - let spa_fallback = ServeFile::new(format!("{ui_dir}/index.html")); - let serve_dir = ServeDir::new(&ui_dir).fallback(spa_fallback); - - Router::new() - .merge(api_router) - .nest_service("/app", client_serve) - .fallback_service(serve_dir) -} - -/// Start the Axum server on the given address. -pub async fn run(addr: &str, state: Arc) -> anyhow::Result<()> { - let router = build_router(state); - let listener = tokio::net::TcpListener::bind(addr).await?; - tracing::info!("listening on {addr}"); - axum::serve(listener, router) - .with_graceful_shutdown(shutdown_signal()) - .await?; - Ok(()) -} - -async fn shutdown_signal() { - tokio::signal::ctrl_c() - .await - .expect("failed to install CTRL+C handler"); - tracing::info!("shutdown signal received"); -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use http_body_util::BodyExt; - use tower::ServiceExt; - - use arc_swap::ArcSwap; - use stackarr_core::config::{AppConfig, EnabledModules}; - use stackarr_core::db::Database; - use stackarr_core::test_helpers::{TestDb, seed_quality_profile}; - use stackarr_download::DownloadClientManager; - use stackarr_indexer::IndexerManager; - use tokio::sync::RwLock; - - async fn test_state() -> (Arc, TestDb) { - let db = TestDb::new().await; - let database = Database::from_pool(db.pool.clone()); - let config = Arc::new(ArcSwap::from_pointee(AppConfig::default())); - let state = Arc::new(AppState { - db: database, - config, - modules: EnabledModules::default(), - torrent_session: None, - torrent_api: None, - usenet_queue: None, - indexarr_client: None, - cardigann_engine: Arc::new(stackarr_cardigann::CardigannEngine::new(std::path::Path::new(""))), - indexer_manager: Arc::new(RwLock::new(IndexerManager::new())), - download_manager: Arc::new(RwLock::new(DownloadClientManager::new())), - rate_limiter: None, - stream_session_manager: None, - }); - (state, db) - } - - async fn body_to_json(body: Body) -> serde_json::Value { - let bytes = body.collect().await.unwrap().to_bytes(); - serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_health_check() { - let (state, db) = test_state().await; - let app = build_router(state); - - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_system_health() { - let (state, db) = test_state().await; - let app = build_router(state); - - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/api/v1/system/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - let json = body_to_json(resp.into_body()).await; - assert_eq!(json["status"], "ok"); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_list_series_empty() { - let (state, db) = test_state().await; - let app = build_router(state); - - // No API key stored = first boot = no auth required - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/api/v1/series") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - let json = body_to_json(resp.into_body()).await; - assert_eq!(json, serde_json::json!([])); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_create_series() { - let (state, db) = test_state().await; - let profile_id = seed_quality_profile(&db.pool).await; - let app = build_router(state); - - let body = serde_json::json!({ - "title": "Breaking Bad", - "path": "/tv/Breaking Bad", - "qualityProfileId": profile_id, - "monitored": true, - "tvdbId": 81189 - }); - - let resp = app - .oneshot( - axum::http::Request::builder() - .method("POST") - .uri("/api/v1/series") - .header("content-type", "application/json") - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - - assert!(resp.status().is_success()); - let json = body_to_json(resp.into_body()).await; - assert_eq!(json["title"], "Breaking Bad"); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_list_tags_empty() { - let (state, db) = test_state().await; - let app = build_router(state); - - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/api/v1/tag") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - let json = body_to_json(resp.into_body()).await; - assert_eq!(json, serde_json::json!([])); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_list_queue_empty() { - let (state, db) = test_state().await; - let app = build_router(state); - - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/api/v1/queue") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - let json = body_to_json(resp.into_body()).await; - assert_eq!(json, serde_json::json!([])); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_list_quality_profiles_empty() { - let (state, db) = test_state().await; - let app = build_router(state); - - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/api/v1/qualityprofile") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - db.cleanup().await; - } - - #[tokio::test] - #[ignore = "requires running postgres"] - async fn test_list_media_library_folders_empty() { - let (state, db) = test_state().await; - let app = build_router(state); - - let resp = app - .oneshot( - axum::http::Request::builder() - .uri("/api/v1/medialibraryfolder") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), 200); - db.cleanup().await; - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/middleware.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/middleware.rs deleted file mode 100644 index 2a8790a6..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/middleware.rs +++ /dev/null @@ -1,560 +0,0 @@ -use std::sync::Arc; - -use axum::extract::FromRequestParts; -use axum::http::request::Parts; -use axum::http::{HeaderMap, StatusCode}; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use serde::Deserialize; -use serde_json::json; - -use crate::AppState; - -/// Extractor that validates the API key from: -/// 1. `X-Api-Key` header (Sonarr/Radarr compatible) -/// 2. `Authorization: Bearer ` header -/// 3. `?apikey=` query parameter -pub struct RequireApiKey; - -#[derive(Deserialize)] -struct ApiKeyQuery { - apikey: Option, -} - -impl RequireApiKey { - fn extract_key(headers: &HeaderMap, query: &str) -> Option { - // 1. X-Api-Key header - if let Some(val) = headers.get("x-api-key") { - if let Ok(s) = val.to_str() { - if !s.is_empty() { - return Some(s.to_string()); - } - } - } - - // 2. Authorization: Bearer - if let Some(val) = headers.get("authorization") { - if let Ok(s) = val.to_str() { - if let Some(token) = s.strip_prefix("Bearer ") { - let token = token.trim(); - if !token.is_empty() { - return Some(token.to_string()); - } - } - } - } - - // 3. ?apikey= query parameter - if let Ok(params) = serde_urlencoded::from_str::(query) { - if let Some(key) = params.apikey { - if !key.is_empty() { - return Some(key); - } - } - } - - None - } -} - -impl FromRequestParts> for RequireApiKey { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let pool = state.db.pool(); - - // Load the stored API key from DB - let stored_key: Option = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'api_key'", - ) - .fetch_optional(pool) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(String::from)); - - // If no API key is stored (first boot), allow all requests - let stored_key = match stored_key { - Some(k) if !k.is_empty() => k, - _ => return Ok(Self), - }; - - let query_str = parts.uri.query().unwrap_or(""); - let provided_key = Self::extract_key(&parts.headers, query_str); - - match provided_key { - Some(key) if key == stored_key => Ok(Self), - Some(_) => Err(( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "invalid API key"})), - ) - .into_response()), - None => Err(( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "API key required — use X-Api-Key header, Authorization: Bearer , or ?apikey= query parameter"})), - ) - .into_response()), - } - } -} - -/// Auth type indicating how the request was authenticated. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthType { - /// Authenticated via admin API key (full access) - ApiKey, - /// Authenticated via remote client token (streaming access only) - ClientToken, -} - -/// Extractor that accepts either the admin API key OR a valid remote client token. -/// Use this on routes that should be accessible to both admins and remote clients -/// (e.g., streaming, library browsing). -pub struct RequireAuth(pub AuthType); - -impl FromRequestParts> for RequireAuth { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let pool = state.db.pool(); - - // Load the stored admin API key - let stored_key: Option = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'api_key'", - ) - .fetch_optional(pool) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(String::from)); - - // If no API key stored (first boot), allow all - let stored_key = match stored_key { - Some(k) if !k.is_empty() => k, - _ => return Ok(Self(AuthType::ApiKey)), - }; - - let query_str = parts.uri.query().unwrap_or(""); - let provided_key = RequireApiKey::extract_key(&parts.headers, query_str); - - let Some(key) = provided_key else { - return Err(( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "authentication required"})), - ) - .into_response()); - }; - - // Check if it matches the admin API key - if key == stored_key { - return Ok(Self(AuthType::ApiKey)); - } - - // Try as a remote client token (UUID format) - if let Ok(token_uuid) = uuid::Uuid::parse_str(&key) { - let valid = state - .db - .validate_remote_client(token_uuid) - .await - .unwrap_or(false); - if valid { - // Update last_seen in background (don't block the request) - let db = state.db.clone(); - tokio::spawn(async move { - let _ = db.touch_remote_client(token_uuid).await; - }); - return Ok(Self(AuthType::ClientToken)); - } - } - - Err(( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "invalid API key or client token"})), - ) - .into_response()) - } -} - -// ── User-based authentication ──────────────────────────────────────────────── - -/// How the current request was authenticated. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthMethod { - /// Authenticated via a session cookie (web login). - Session, - /// Authenticated via a device token (Bearer UUID). - DeviceToken, - /// Authenticated via the legacy API key. - ApiKey, -} - -/// The authenticated user extracted from the request. -#[derive(Debug, Clone)] -pub struct AuthenticatedUser { - pub user_id: i64, - pub username: String, - pub role: String, - pub auth_method: AuthMethod, -} - -/// Extractor that requires a logged-in user. -/// -/// Resolution order: -/// 1. `stackarr_session` cookie -> hash -> validate_session -/// 2. Bearer token (UUID) -> validate_user_device -/// 3. X-Api-Key / ?apikey= / Bearer (non-UUID) -> match legacy API key -/// 4. First-boot bypass if no users exist -/// 5. Return 401 -pub struct RequireUser(pub AuthenticatedUser); - -fn extract_cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { - headers.get("cookie")?.to_str().ok().and_then(|cookies| { - cookies.split(';').find_map(|c| { - let c = c.trim(); - c.strip_prefix(name) - .and_then(|rest| rest.strip_prefix('=')) - }) - }) -} - -impl FromRequestParts> for RequireUser { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - // 1. Try session cookie - if let Some(session_token) = extract_cookie(&parts.headers, "stackarr_session") { - if !session_token.is_empty() { - let token_hash = stackarr_core::auth::hash_token(session_token); - if let Ok(Some(user)) = state.db.validate_session(&token_hash).await { - // Touch session in background - let db = state.db.clone(); - let hash = token_hash.clone(); - tokio::spawn(async move { - let _ = db.touch_session(&hash).await; - }); - return Ok(Self(AuthenticatedUser { - user_id: user.id, - username: user.username, - role: user.role, - auth_method: AuthMethod::Session, - })); - } - } - } - - // Extract bearer / api key - let query_str = parts.uri.query().unwrap_or(""); - let provided_key = RequireApiKey::extract_key(&parts.headers, query_str); - - if let Some(ref key) = provided_key { - // 2. Try as device token (UUID format) - if let Ok(token_uuid) = uuid::Uuid::parse_str(key) { - if let Ok(Some(user)) = state.db.validate_user_device(token_uuid).await { - let db = state.db.clone(); - tokio::spawn(async move { - let _ = db.touch_user_device(token_uuid).await; - }); - return Ok(Self(AuthenticatedUser { - user_id: user.id, - username: user.username, - role: user.role, - auth_method: AuthMethod::DeviceToken, - })); - } - - // Also try legacy remote_clients for backward compat - if let Ok(true) = state.db.validate_remote_client(token_uuid).await { - let db = state.db.clone(); - tokio::spawn(async move { - let _ = db.touch_remote_client(token_uuid).await; - }); - // Legacy client token — treat as a basic user - return Ok(Self(AuthenticatedUser { - user_id: 0, - username: "client".to_string(), - role: "user".to_string(), - auth_method: AuthMethod::DeviceToken, - })); - } - } - - // 3. Try as legacy API key - let stored_key: Option = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'api_key'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(String::from)); - - if let Some(ref stored) = stored_key { - if !stored.is_empty() && key == stored { - return Ok(Self(AuthenticatedUser { - user_id: 0, - username: "admin".to_string(), - role: "admin".to_string(), - auth_method: AuthMethod::ApiKey, - })); - } - } - } - - // 4. First-boot bypass: if no users exist, allow unauthenticated access - if let Ok(0) = state.db.count_users().await { - // Also check if no API key is stored - let has_api_key: bool = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'api_key'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(|s| !s.is_empty())) - .unwrap_or(false); - - if !has_api_key { - return Ok(Self(AuthenticatedUser { - user_id: 0, - username: "admin".to_string(), - role: "admin".to_string(), - auth_method: AuthMethod::ApiKey, - })); - } - } - - // 5. Unauthorized - Err(( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "authentication required"})), - ) - .into_response()) - } -} - -/// Extractor that requires an admin user. -/// Returns 403 if the user is not an admin. -pub struct RequireAdmin(pub AuthenticatedUser); - -impl FromRequestParts> for RequireAdmin { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let RequireUser(user) = RequireUser::from_request_parts(parts, state).await?; - - if user.role != "admin" { - return Err(( - StatusCode::FORBIDDEN, - Json(json!({"error": "admin access required"})), - ) - .into_response()); - } - - Ok(Self(user)) - } -} - -/// Mask a string, showing only the first 4 and last 4 characters. -pub fn mask_secret(s: &str) -> String { - if s.len() <= 8 { - "*".repeat(s.len()) - } else { - format!("{}…{}", &s[..4], &s[s.len() - 4..]) - } -} - -/// Redact known sensitive fields in a JSON value (mutates in place). -pub fn redact_sensitive_fields(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(map) => { - for (key, val) in map.iter_mut() { - let lower = key.to_lowercase(); - if lower.contains("api_key") - || lower.contains("apikey") - || lower == "auth_token" - || lower == "authtoken" - || lower.contains("password") - || lower.contains("secret") - || lower.contains("token") - && !lower.contains("token_refresh") - && !lower.contains("plex_rating_key") - { - if let serde_json::Value::String(s) = val { - if !s.is_empty() { - *val = serde_json::Value::String(mask_secret(s)); - } - } - } else { - redact_sensitive_fields(val); - } - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - redact_sensitive_fields(item); - } - } - _ => {} - } -} - -// ── Rate limiting ──────────────────────────────────────────────────────────── - -use std::net::IpAddr; -use std::num::NonZeroU32; - -use governor::clock::DefaultClock; -use governor::state::keyed::DashMapStateStore; -use governor::{Quota, RateLimiter}; - -/// Shared rate limiter keyed by client IP address. -pub type KeyedRateLimiter = RateLimiter, DefaultClock>; - -/// Create a rate limiter that allows `per_second` requests per second per IP. -pub fn create_rate_limiter(per_second: u32) -> Arc { - let quota = Quota::per_second(NonZeroU32::new(per_second).unwrap_or(NonZeroU32::MIN)); - Arc::new(RateLimiter::keyed(quota)) -} - -/// Extract the client IP from request headers or connection info. -pub fn client_ip(parts: &Parts) -> IpAddr { - // Try X-Forwarded-For first (behind reverse proxy) - if let Some(forwarded) = parts.headers.get("x-forwarded-for") { - if let Ok(s) = forwarded.to_str() { - if let Some(first) = s.split(',').next() { - if let Ok(ip) = first.trim().parse::() { - return ip; - } - } - } - } - // Try X-Real-IP - if let Some(real_ip) = parts.headers.get("x-real-ip") { - if let Ok(s) = real_ip.to_str() { - if let Ok(ip) = s.trim().parse::() { - return ip; - } - } - } - // Fallback to loopback - IpAddr::V4(std::net::Ipv4Addr::LOCALHOST) -} - -/// Rate limit extractor — returns 429 if the client exceeds the limit. -pub struct RateLimit; - -impl FromRequestParts> for RateLimit { - type Rejection = Response; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let ip = client_ip(parts); - if let Some(ref limiter) = state.rate_limiter { - match limiter.check_key(&ip) { - Ok(_) => Ok(Self), - Err(_) => Err(( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({"error": "rate limit exceeded — try again later"})), - ) - .into_response()), - } - } else { - Ok(Self) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_mask_secret_long() { - assert_eq!(mask_secret("abcdefghijklmnop"), "abcd…mnop"); - } - - #[test] - fn test_mask_secret_short() { - assert_eq!(mask_secret("abc"), "***"); - } - - #[test] - fn test_mask_secret_exact_8() { - assert_eq!(mask_secret("12345678"), "********"); - } - - #[test] - fn test_redact_sensitive_fields() { - let mut val = json!({ - "name": "NZBGeek", - "api_key": "supersecretkey123456", - "base_url": "https://example.com", - "config": { - "password": "hunter2", - "host": "localhost" - } - }); - redact_sensitive_fields(&mut val); - assert_eq!(val["name"], "NZBGeek"); - assert_ne!(val["api_key"], "supersecretkey123456"); - assert_eq!(val["base_url"], "https://example.com"); - assert_ne!(val["config"]["password"], "hunter2"); - assert_eq!(val["config"]["host"], "localhost"); - } - - #[test] - fn test_redact_empty_string_unchanged() { - let mut val = json!({"api_key": ""}); - redact_sensitive_fields(&mut val); - assert_eq!(val["api_key"], ""); - } - - #[test] - fn test_extract_key_x_api_key() { - let mut headers = HeaderMap::new(); - headers.insert("x-api-key", "test-key-123".parse().unwrap()); - assert_eq!( - RequireApiKey::extract_key(&headers, ""), - Some("test-key-123".to_string()) - ); - } - - #[test] - fn test_extract_key_bearer() { - let mut headers = HeaderMap::new(); - headers.insert("authorization", "Bearer my-token".parse().unwrap()); - assert_eq!( - RequireApiKey::extract_key(&headers, ""), - Some("my-token".to_string()) - ); - } - - #[test] - fn test_extract_key_query() { - let headers = HeaderMap::new(); - assert_eq!( - RequireApiKey::extract_key(&headers, "apikey=qp-key&other=val"), - Some("qp-key".to_string()) - ); - } - - #[test] - fn test_extract_key_none() { - let headers = HeaderMap::new(); - assert_eq!(RequireApiKey::extract_key(&headers, ""), None); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/admin.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/admin.rs deleted file mode 100644 index d3485db7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/admin.rs +++ /dev/null @@ -1,437 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, post, put}; -use axum::{Json, Router}; -use chrono::Utc; -use serde::Deserialize; -use serde_json::json; - -use crate::middleware::RequireAdmin; -use crate::AppState; - -// ── User management ────────────────────────────────────────────────────────── - -async fn list_users( - State(state): State>, - RequireAdmin(_admin): RequireAdmin, -) -> impl IntoResponse { - match state.db.list_users().await { - Ok(users) => { - let users_json: Vec = users - .iter() - .map(|u| { - json!({ - "id": u.id, - "username": u.username, - "displayName": u.display_name, - "role": u.role, - "avatarUrl": u.avatar_url, - "enabled": u.enabled, - "createdAt": u.created_at, - "updatedAt": u.updated_at, - }) - }) - .collect(); - Json(serde_json::Value::Array(users_json)).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to list users"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateUserRequest { - username: String, - password: String, - display_name: Option, - role: Option, -} - -async fn create_user( - State(state): State>, - RequireAdmin(_admin): RequireAdmin, - Json(body): Json, -) -> impl IntoResponse { - let username = body.username.trim().to_lowercase(); - if username.is_empty() || body.password.len() < 6 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "username required and password must be at least 6 characters"})), - ) - .into_response(); - } - - let role = body.role.as_deref().unwrap_or("user"); - if role != "admin" && role != "user" { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "role must be 'admin' or 'user'"})), - ) - .into_response(); - } - - let password = body.password.clone(); - let password_hash = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::hash_password(&password) - }) - .await - { - Ok(Ok(h)) => h, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let display_name = body - .display_name - .as_deref() - .unwrap_or(&username) - .to_string(); - - match state - .db - .create_user(&username, &display_name, &password_hash, role) - .await - { - Ok(user) => ( - StatusCode::CREATED, - Json(json!({ - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "enabled": user.enabled, - "createdAt": user.created_at, - })), - ) - .into_response(), - Err(e) => { - let msg = e.to_string(); - if msg.contains("unique") || msg.contains("duplicate") { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "username already taken"})), - ) - .into_response(); - } - tracing::error!(error = %e, "failed to create user"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateUserRequest { - display_name: Option, - role: Option, - enabled: Option, - avatar_url: Option, - password: Option, -} - -async fn update_user( - State(state): State>, - RequireAdmin(_admin): RequireAdmin, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - // Get current user data - let existing = match state.db.get_user_by_id(id).await { - Ok(Some(u)) => u, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to get user"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let display_name = body - .display_name - .as_deref() - .unwrap_or(&existing.display_name); - let role = body.role.as_deref().unwrap_or(&existing.role); - let enabled = body.enabled.unwrap_or(existing.enabled); - let avatar_url = body.avatar_url.as_deref().or(existing.avatar_url.as_deref()); - - // Update password if provided - if let Some(ref password) = body.password { - if password.len() < 6 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "password must be at least 6 characters"})), - ) - .into_response(); - } - let pw = password.clone(); - let hash = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::hash_password(&pw) - }) - .await - { - Ok(Ok(h)) => h, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - let _ = state.db.update_user_password(id, &hash).await; - } - - match state - .db - .update_user(id, display_name, role, enabled, avatar_url) - .await - { - Ok(Some(user)) => Json(json!({ - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "enabled": user.enabled, - "avatarUrl": user.avatar_url, - "createdAt": user.created_at, - "updatedAt": user.updated_at, - })) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to update user"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_user( - State(state): State>, - RequireAdmin(admin): RequireAdmin, - Path(id): Path, -) -> impl IntoResponse { - // Prevent self-deletion - if admin.user_id == id { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "cannot delete your own account"})), - ) - .into_response(); - } - - match state.db.delete_user(id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete user"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Invite management ──────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateInviteRequest { - role: Option, - expires_in_hours: Option, -} - -async fn create_invite( - State(state): State>, - RequireAdmin(admin): RequireAdmin, - Json(body): Json, -) -> impl IntoResponse { - let role = body.role.as_deref().unwrap_or("user"); - if role != "admin" && role != "user" { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "role must be 'admin' or 'user'"})), - ) - .into_response(); - } - - let code = stackarr_core::auth::generate_invite_code(); - let expires_at = body - .expires_in_hours - .map(|h| Utc::now() + chrono::Duration::hours(h)); - - let invite = match state - .db - .create_invite(&code, admin.user_id, role, expires_at) - .await - { - Ok(inv) => inv, - Err(e) => { - tracing::error!(error = %e, "failed to create invite"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - // Register with bootstrap for unified server discovery (best-effort) - let config = state.config.load(); - if config.bootstrap.enabled { - if let (Some(url), Some(token)) = ( - config.bootstrap.url.as_ref(), - config.bootstrap.token.as_ref(), - ) { - let server_id = state.db.ensure_server_id().await.ok(); - if let Some(server_id) = server_id { - let ttl_secs = invite - .expires_at - .map(|exp| (exp - Utc::now()).num_seconds().max(0) as u64) - .unwrap_or(86400); // 24h default for no-expiry invites - - let client = reqwest::Client::new(); - match client - .post(format!("{url}/api/v1/claims")) - .bearer_auth(token) - .json(&json!({ - "serverId": server_id, - "code": invite.code, - "claimType": "invite", - "inviteCode": invite.code, - "ttlSecs": ttl_secs, - })) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - tracing::info!(code = %invite.code, "invite registered with bootstrap"); - } - Ok(resp) => { - tracing::warn!( - code = %invite.code, - status = %resp.status(), - "failed to register invite with bootstrap" - ); - } - Err(e) => { - tracing::warn!( - code = %invite.code, - error = %e, - "failed to reach bootstrap for invite registration" - ); - } - } - } - } - } - - ( - StatusCode::CREATED, - Json(json!({ - "id": invite.id, - "code": invite.code, - "role": invite.role, - "expiresAt": invite.expires_at, - "createdAt": invite.created_at, - })), - ) - .into_response() -} - -async fn list_invites( - State(state): State>, - RequireAdmin(_admin): RequireAdmin, -) -> impl IntoResponse { - match state.db.list_invites().await { - Ok(invites) => Json(serde_json::to_value(invites).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list invites"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_invite( - State(state): State>, - RequireAdmin(_admin): RequireAdmin, - Path(id): Path, -) -> impl IntoResponse { - match state.db.delete_invite(id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "invite not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete invite"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/admin/users", get(list_users).post(create_user)) - .route( - "/api/v1/admin/users/{id}", - put(update_user).delete(delete_user), - ) - .route( - "/api/v1/admin/invites", - get(list_invites).post(create_invite), - ) - .route("/api/v1/admin/invites/{id}", delete(delete_invite)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/auth.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/auth.rs deleted file mode 100644 index d28263db..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/auth.rs +++ /dev/null @@ -1,634 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use chrono::Utc; -use serde::Deserialize; -use serde_json::json; - -use uuid::Uuid; - -use crate::middleware::{RequireUser, RateLimit, client_ip}; -use crate::AppState; - -// ── Login ──────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct LoginRequest { - username: String, - password: String, - device_name: Option, // if provided, also create a user_device and return deviceToken -} - -async fn login( - State(state): State>, - _rate_limit: RateLimit, - axum::http::request::Parts { headers, uri, .. }: axum::http::request::Parts, - Json(body): Json, -) -> impl IntoResponse { - // This handler needs manual extraction since we need Parts for IP/UA - // but we're using Json body too. We'll use a simpler approach. - let device_name = body.device_name.clone(); - login_inner(state, body, None, None, device_name).await -} - -async fn login_handler( - State(state): State>, - _rate_limit: RateLimit, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - let user_agent = headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - let ip = headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .map(|s| s.trim().to_string()) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|v| v.to_str().ok()) - .map(|s| s.trim().to_string()) - }); - - let device_name = body.device_name.clone(); - login_inner(state, body, user_agent, ip, device_name).await -} - -async fn login_inner( - state: Arc, - body: LoginRequest, - user_agent: Option, - ip_address: Option, - device_name: Option, -) -> impl IntoResponse { - let username = body.username.trim(); - if username.is_empty() || body.password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "username and password are required"})), - ) - .into_response(); - } - - // Look up user - let user = match state.db.get_user_by_username(username).await { - Ok(Some(u)) => u, - Ok(None) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "invalid username or password"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "login: failed to query user"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - if !user.enabled { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "account is disabled"})), - ) - .into_response(); - } - - // Verify password (blocking operation) - let hash = user.password_hash.clone(); - let password = body.password.clone(); - let valid = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::verify_password(&password, &hash) - }) - .await - { - Ok(Ok(v)) => v, - _ => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "invalid username or password"})), - ) - .into_response(); - } - }; - - if !valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "invalid username or password"})), - ) - .into_response(); - } - - // Create session - let token = stackarr_core::auth::generate_session_token(); - let token_hash = stackarr_core::auth::hash_token(&token); - let expires_at = Utc::now() + chrono::Duration::days(30); - - if let Err(e) = state - .db - .create_session( - user.id, - &token_hash, - user_agent.as_deref(), - ip_address.as_deref(), - expires_at, - ) - .await - { - tracing::error!(error = %e, "login: failed to create session"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - - // Create device token if device_name was provided (mobile/Tauri clients) - let device_token = if let Some(ref name) = device_name { - let dt = Uuid::new_v4(); - match state - .db - .create_user_device(user.id, dt, Some(name.as_str()), None) - .await - { - Ok(_) => Some(dt), - Err(e) => { - tracing::warn!(error = %e, "login: failed to create user device"); - None - } - } - } else { - None - }; - - // Build Set-Cookie header - let cookie = format!( - "stackarr_session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}", - 30 * 24 * 60 * 60 - ); - - let mut response_json = json!({ - "user": { - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "avatarUrl": user.avatar_url, - }, - "token": token, - }); - - if let Some(dt) = device_token { - response_json["deviceToken"] = json!(dt); - } - - let mut response = Json(response_json).into_response(); - - response - .headers_mut() - .insert("set-cookie", cookie.parse().expect("valid cookie header")); - - response -} - -// ── Logout ─────────────────────────────────────────────────────────────────── - -async fn logout( - State(state): State>, - RequireUser(user): RequireUser, - headers: axum::http::HeaderMap, -) -> impl IntoResponse { - // Extract session token from cookie and delete it - if let Some(session_token) = extract_session_cookie(&headers) { - let token_hash = stackarr_core::auth::hash_token(session_token); - let _ = state.db.delete_session(&token_hash).await; - } - - // Clear cookie - let cookie = "stackarr_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"; - - let mut response = Json(json!({"ok": true})).into_response(); - response - .headers_mut() - .insert("set-cookie", cookie.parse().expect("valid cookie header")); - - let _ = user; // suppress unused warning - response -} - -fn extract_session_cookie<'a>(headers: &'a axum::http::HeaderMap) -> Option<&'a str> { - headers.get("cookie")?.to_str().ok().and_then(|cookies| { - cookies.split(';').find_map(|c| { - let c = c.trim(); - c.strip_prefix("stackarr_session=") - }) - }) -} - -// ── Register (with invite code) ────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RegisterRequest { - username: String, - password: String, - display_name: Option, - invite_code: String, -} - -async fn register( - State(state): State>, - _rate_limit: RateLimit, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - let username = body.username.trim().to_lowercase(); - if username.is_empty() || body.password.len() < 6 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "username required and password must be at least 6 characters"})), - ) - .into_response(); - } - - // Validate invite - let invite = match state.db.validate_invite(&body.invite_code).await { - Ok(Some(inv)) => inv, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "invalid or expired invite code"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "register: failed to validate invite"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - // Hash password - let password = body.password.clone(); - let password_hash = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::hash_password(&password) - }) - .await - { - Ok(Ok(h)) => h, - Ok(Err(e)) => { - tracing::error!(error = %e, "register: failed to hash password"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "register: spawn_blocking failed"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let display_name = body - .display_name - .as_deref() - .unwrap_or(&username) - .to_string(); - - // Create user - let user = match state - .db - .create_user(&username, &display_name, &password_hash, &invite.role) - .await - { - Ok(u) => u, - Err(e) => { - let msg = e.to_string(); - if msg.contains("unique") || msg.contains("duplicate") { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "username already taken"})), - ) - .into_response(); - } - tracing::error!(error = %e, "register: failed to create user"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - // Claim invite - let _ = state.db.claim_invite(&body.invite_code, user.id).await; - - // Create session - let token = stackarr_core::auth::generate_session_token(); - let token_hash = stackarr_core::auth::hash_token(&token); - let expires_at = Utc::now() + chrono::Duration::days(30); - - let user_agent = headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - let ip = headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .map(|s| s.trim().to_string()) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|v| v.to_str().ok()) - .map(|s| s.trim().to_string()) - }); - - let _ = state - .db - .create_session( - user.id, - &token_hash, - user_agent.as_deref(), - ip.as_deref(), - expires_at, - ) - .await; - - let cookie = format!( - "stackarr_session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}", - 30 * 24 * 60 * 60 - ); - - let mut response = Json(json!({ - "user": { - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "avatarUrl": user.avatar_url, - }, - "token": token, - })) - .into_response(); - - response - .headers_mut() - .insert("set-cookie", cookie.parse().expect("valid cookie header")); - - (StatusCode::CREATED, response).into_response() -} - -// ── Me ─────────────────────────────────────────────────────────────────────── - -async fn me( - State(state): State>, - RequireUser(auth_user): RequireUser, -) -> impl IntoResponse { - // If user_id == 0, this is a legacy API key or first-boot bypass - if auth_user.user_id == 0 { - return Json(json!({ - "id": 0, - "username": auth_user.username, - "displayName": auth_user.username, - "role": auth_user.role, - "avatarUrl": serde_json::Value::Null, - "authMethod": format!("{:?}", auth_user.auth_method).to_lowercase(), - })) - .into_response(); - } - - match state.db.get_user_by_id(auth_user.user_id).await { - Ok(Some(user)) => Json(json!({ - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "avatarUrl": user.avatar_url, - "authMethod": format!("{:?}", auth_user.auth_method).to_lowercase(), - })) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "me: failed to fetch user"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Auth Status (public) ───────────────────────────────────────────────────── - -async fn auth_status(State(state): State>) -> impl IntoResponse { - let setup_required = matches!(state.db.count_users().await, Ok(0)); - Json(json!({ - "setupRequired": setup_required, - "registrationEnabled": true, - })) -} - -// ── First-Boot Setup ───────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SetupRequest { - username: String, - password: String, - display_name: Option, -} - -async fn setup( - State(state): State>, - headers: axum::http::HeaderMap, - Json(body): Json, -) -> impl IntoResponse { - // Only allow setup when no users exist - match state.db.count_users().await { - Ok(0) => {} - Ok(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "setup already completed"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "setup: failed to count users"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - - let username = body.username.trim().to_lowercase(); - if username.is_empty() || body.password.len() < 6 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "username required and password must be at least 6 characters"})), - ) - .into_response(); - } - - // Hash password - let password = body.password.clone(); - let password_hash = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::hash_password(&password) - }) - .await - { - Ok(Ok(h)) => h, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let display_name = body - .display_name - .as_deref() - .unwrap_or(&username) - .to_string(); - - // Create admin user - let user = match state - .db - .create_user(&username, &display_name, &password_hash, "admin") - .await - { - Ok(u) => u, - Err(e) => { - let msg = e.to_string(); - if msg.contains("unique") || msg.contains("duplicate") { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "username already taken"})), - ) - .into_response(); - } - tracing::error!(error = %e, "setup: failed to create admin user"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - tracing::info!(username = %user.username, "first-boot admin user created via setup"); - - // Generate and store API key - let api_key = stackarr_core::auth::generate_session_token(); - if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('api_key', $1) \ - ON CONFLICT (key) DO UPDATE SET value = $1", - ) - .bind(serde_json::json!(&api_key)) - .execute(state.db.pool()) - .await - { - tracing::warn!(error = %e, "setup: failed to store API key"); - } - - // Create session - let token = stackarr_core::auth::generate_session_token(); - let token_hash = stackarr_core::auth::hash_token(&token); - let expires_at = Utc::now() + chrono::Duration::days(30); - - let user_agent = headers - .get("user-agent") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - let ip = headers - .get("x-forwarded-for") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.split(',').next()) - .map(|s| s.trim().to_string()) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|v| v.to_str().ok()) - .map(|s| s.trim().to_string()) - }); - - let _ = state - .db - .create_session( - user.id, - &token_hash, - user_agent.as_deref(), - ip.as_deref(), - expires_at, - ) - .await; - - let cookie = format!( - "stackarr_session={token}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}", - 30 * 24 * 60 * 60 - ); - - let mut response = Json(json!({ - "user": { - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "avatarUrl": user.avatar_url, - }, - "token": token, - })) - .into_response(); - - response - .headers_mut() - .insert("set-cookie", cookie.parse().expect("valid cookie header")); - - (StatusCode::CREATED, response).into_response() -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/auth/status", get(auth_status)) - .route("/api/v1/auth/setup", post(setup)) - .route("/api/v1/auth/login", post(login_handler)) - .route("/api/v1/auth/logout", post(logout)) - .route("/api/v1/auth/register", post(register)) - .route("/api/v1/auth/me", get(me)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/backup.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/backup.rs deleted file mode 100644 index c7b05364..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/backup.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use sqlx::Column; - -use crate::middleware::RequireApiKey; -use crate::AppState; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BackupResponse { - series: Vec, - movies: Vec, - quality_profiles: Vec, - tags: Vec, - indexers: Vec, - download_clients: Vec, - media_library_folders: Vec, - naming_config: Vec, - notification_providers: Vec, - import_lists: Vec, - enabled_modules: Vec, -} - -/// GET /api/v1/system/backup — export the database as JSON. -async fn export_backup( - _auth: RequireApiKey, - State(state): State>, -) -> impl IntoResponse { - let pool = state.db.pool(); - - macro_rules! fetch_table { - ($q:expr) => { - match sqlx::query($q).fetch_all(pool).await { - Ok(rows) => { - use sqlx::Row; - rows.iter() - .map(|r| { - let cols = r.columns(); - let mut obj = serde_json::Map::new(); - for col in cols { - let name = col.name(); - // Try common types - if let Ok(v) = r.try_get::(name) { - obj.insert(name.to_string(), v); - } else if let Ok(v) = r.try_get::(name) { - obj.insert(name.to_string(), json!(v)); - } else if let Ok(v) = r.try_get::(name) { - obj.insert(name.to_string(), json!(v)); - } else if let Ok(v) = r.try_get::(name) { - obj.insert(name.to_string(), json!(v)); - } else if let Ok(v) = r.try_get::(name) { - obj.insert(name.to_string(), json!(v)); - } else { - obj.insert(name.to_string(), serde_json::Value::Null); - } - } - serde_json::Value::Object(obj) - }) - .collect::>() - } - Err(e) => { - tracing::error!(table = $q, error = %e, "backup: failed to export table"); - Vec::new() - } - } - }; - } - - let resp = BackupResponse { - series: fetch_table!("SELECT id, title, path, quality_profile_id, media_library_folder_id, monitored, tvdb_id, imdb_id, tmdb_id FROM series ORDER BY id"), - movies: fetch_table!("SELECT id, title, path, quality_profile_id, media_library_folder_id, monitored, tmdb_id, imdb_id, minimum_availability FROM movies ORDER BY id"), - quality_profiles: fetch_table!("SELECT id, name, cutoff, upgrade_allowed, min_format_score, cutoff_format_score, items, media_type FROM quality_profiles ORDER BY id"), - tags: fetch_table!("SELECT id, label FROM tags ORDER BY id"), - indexers: fetch_table!("SELECT id, name, indexer_type, base_url, protocol, categories, enabled, priority, supports_search, supports_rss FROM indexers ORDER BY id"), - download_clients: fetch_table!("SELECT id, name, client_type, protocol, enabled, priority FROM download_clients ORDER BY id"), - media_library_folders: fetch_table!("SELECT id, path, media_type FROM media_library_folders ORDER BY id"), - naming_config: fetch_table!("SELECT id, media_type, rename_files, standard_format, daily_format, anime_format, season_folder_format, movie_format, movie_folder_format FROM naming_config ORDER BY id"), - notification_providers: fetch_table!("SELECT id, name, provider_type, on_grab, on_import, on_upgrade, on_health_issue, on_failure, enabled FROM notification_providers ORDER BY id"), - import_lists: fetch_table!("SELECT id, name, list_type, media_type, enabled FROM import_lists ORDER BY id"), - enabled_modules: fetch_table!("SELECT id, module, enabled FROM enabled_modules ORDER BY id"), - }; - - Json(resp).into_response() -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -struct RestoreRequest { - quality_profiles: Option>, - tags: Option>, - media_library_folders: Option>, - naming_config: Option>, - enabled_modules: Option>, -} - -/// POST /api/v1/system/restore — import configuration from a backup JSON. -/// -/// Only restores configuration tables (quality profiles, tags, folders, naming, modules). -/// Media (series/movies) must be re-imported via migration or TMDB lookup. -async fn import_restore( - _auth: RequireApiKey, - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - let mut restored = Vec::new(); - let mut errors = Vec::new(); - - // Restore tags - if let Some(tags) = &body.tags { - let mut count = 0; - for tag in tags { - let label = tag.get("label").and_then(|v| v.as_str()).unwrap_or(""); - if label.is_empty() { - continue; - } - match sqlx::query("INSERT INTO tags (label) VALUES ($1) ON CONFLICT (label) DO NOTHING") - .bind(label) - .execute(pool) - .await - { - Ok(_) => count += 1, - Err(e) => { - tracing::error!(error = %e, "restore: failed to insert tag"); - errors.push(format!("tag '{label}': insert failed")); - } - } - } - restored.push(format!("tags: {count}")); - } - - // Restore media library folders - if let Some(folders) = &body.media_library_folders { - let mut count = 0; - for folder in folders { - let path = folder.get("path").and_then(|v| v.as_str()).unwrap_or(""); - let media_type = folder.get("media_type").or_else(|| folder.get("mediaType")) - .and_then(|v| v.as_str()).unwrap_or("series"); - if path.is_empty() { - continue; - } - match sqlx::query( - "INSERT INTO media_library_folders (path, media_type) VALUES ($1, $2) ON CONFLICT (path) DO NOTHING" - ) - .bind(path) - .bind(media_type) - .execute(pool) - .await - { - Ok(_) => count += 1, - Err(e) => { - tracing::error!(error = %e, "restore: failed to insert folder"); - errors.push(format!("folder '{path}': insert failed")); - } - } - } - restored.push(format!("media_library_folders: {count}")); - } - - // Restore enabled modules - if let Some(modules) = &body.enabled_modules { - let mut count = 0; - for m in modules { - let module = m.get("module").and_then(|v| v.as_str()).unwrap_or(""); - let enabled = m.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); - if module.is_empty() { - continue; - } - match sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) ON CONFLICT (module) DO UPDATE SET enabled = $2" - ) - .bind(module) - .bind(enabled) - .execute(pool) - .await - { - Ok(_) => count += 1, - Err(e) => { - tracing::error!(error = %e, "restore: failed to insert module"); - errors.push(format!("module '{module}': insert failed")); - } - } - } - restored.push(format!("enabled_modules: {count}")); - } - - let status = if errors.is_empty() { - StatusCode::OK - } else { - StatusCode::MULTI_STATUS - }; - - ( - status, - Json(json!({ - "success": errors.is_empty(), - "restored": restored, - "errors": errors, - })), - ) - .into_response() -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/system/backup", get(export_backup)) - .route("/api/v1/system/restore", post(import_restore)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/blocklist.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/blocklist.rs deleted file mode 100644 index 075e6fca..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/blocklist.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::middleware::RequireApiKey; -use crate::AppState; - -#[derive(Deserialize)] -struct PaginationParams { - #[serde(default = "default_page")] - page: i64, - #[serde(default = "default_page_size", alias = "pageSize")] - page_size: i64, -} - -fn default_page() -> i64 { 1 } -fn default_page_size() -> i64 { 25 } - -#[derive(Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct BlocklistEntry { - id: i64, - media_type: String, - media_id: i64, - source_title: String, - quality: serde_json::Value, - languages: Option, - indexer_id: Option, - info_hash: Option, - message: Option, - added_at: chrono::DateTime, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateBlocklistEntry { - media_type: String, - media_id: i64, - source_title: String, - quality: serde_json::Value, - languages: Option, - indexer_id: Option, - info_hash: Option, - message: Option, -} - -#[derive(Deserialize)] -struct BulkDeleteRequest { - ids: Vec, -} - -/// GET /api/v1/blocklist -async fn list_blocklist( - _auth: RequireApiKey, - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - let pool = state.db.pool(); - let page = params.page.max(1); - let page_size = params.page_size.clamp(1, 250); - let offset = (page - 1) * page_size; - - let total: i64 = match sqlx::query_scalar("SELECT COUNT(*) FROM blocklist") - .fetch_one(pool) - .await - { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %e, "blocklist: failed to count entries"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to retrieve blocklist"})), - ) - .into_response(); - } - }; - - match sqlx::query_as::<_, BlocklistEntry>( - "SELECT id, media_type, media_id, source_title, quality, languages, - indexer_id, info_hash, message, added_at - FROM blocklist ORDER BY added_at DESC LIMIT $1 OFFSET $2", - ) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await - { - Ok(records) => Json(json!({ - "page": page, - "pageSize": page_size, - "totalRecords": total, - "records": records, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "blocklist: failed to fetch entries"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to retrieve blocklist"})), - ) - .into_response() - } - } -} - -/// POST /api/v1/blocklist -async fn add_blocklist_entry( - _auth: RequireApiKey, - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, BlocklistEntry>( - "INSERT INTO blocklist (media_type, media_id, source_title, quality, languages, indexer_id, info_hash, message) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, media_type, media_id, source_title, quality, languages, indexer_id, info_hash, message, added_at", - ) - .bind(&body.media_type) - .bind(body.media_id) - .bind(&body.source_title) - .bind(&body.quality) - .bind(&body.languages) - .bind(body.indexer_id) - .bind(&body.info_hash) - .bind(&body.message) - .fetch_one(pool) - .await - { - Ok(entry) => (StatusCode::CREATED, Json(json!(entry))).into_response(), - Err(e) => { - tracing::error!(error = %e, "blocklist: failed to add entry"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to add blocklist entry"})), - ) - .into_response() - } - } -} - -/// DELETE /api/v1/blocklist/{id} -async fn delete_blocklist_entry( - _auth: RequireApiKey, - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query("DELETE FROM blocklist WHERE id = $1") - .bind(id) - .execute(pool) - .await - { - Ok(r) if r.rows_affected() == 0 => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "blocklist entry not found"})), - ) - .into_response(), - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(e) => { - tracing::error!(error = %e, "blocklist: failed to delete entry"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to delete blocklist entry"})), - ) - .into_response() - } - } -} - -/// DELETE /api/v1/blocklist/bulk -async fn bulk_delete_blocklist( - _auth: RequireApiKey, - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - if body.ids.is_empty() { - return Json(json!({"deleted": 0})).into_response(); - } - - let pool = state.db.pool(); - match sqlx::query("DELETE FROM blocklist WHERE id = ANY($1)") - .bind(&body.ids) - .execute(pool) - .await - { - Ok(r) => Json(json!({"deleted": r.rows_affected()})).into_response(), - Err(e) => { - tracing::error!(error = %e, "blocklist: failed to bulk delete"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to delete blocklist entries"})), - ) - .into_response() - } - } -} - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/blocklist", - get(list_blocklist).post(add_blocklist_entry), - ) - .route("/api/v1/blocklist/{id}", delete(delete_blocklist_entry)) - .route("/api/v1/blocklist/bulk", delete(bulk_delete_blocklist)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/bootstrap.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/bootstrap.rs deleted file mode 100644 index f6346c15..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/bootstrap.rs +++ /dev/null @@ -1,315 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::AppState; - -// ── Helper: extract bootstrap config values ────────────────────────────────── - -struct BootstrapContext { - url: String, - token: String, - server_id: uuid::Uuid, -} - -async fn bootstrap_context(state: &AppState) -> Result)> { - let config = state.config.load(); - - let url = match config.bootstrap.url.as_ref() { - Some(url) if config.bootstrap.enabled => url.clone(), - _ => { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "bootstrap not configured"})), - )); - } - }; - - let token = match config.bootstrap.token.as_ref() { - Some(t) => t.clone(), - None => { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({"error": "bootstrap token not configured"})), - )); - } - }; - - let server_id = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'server_id'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().and_then(|s| uuid::Uuid::parse_str(s).ok())); - - let server_id = match server_id { - Some(id) => id, - None => { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "server_id not found"})), - )); - } - }; - - Ok(BootstrapContext { url, token, server_id }) -} - -// ── Register server name ───────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RegisterNameRequest { - server_name: Option, -} - -async fn register_name( - State(state): State>, - _: crate::middleware::RequireApiKey, - Json(body): Json, -) -> impl IntoResponse { - let ctx = match bootstrap_context(&state).await { - Ok(ctx) => ctx, - Err(e) => return e.into_response(), - }; - - let config = state.config.load(); - let server_name = body - .server_name - .unwrap_or_else(|| config.general.instance_name.clone()); - - let client = reqwest::Client::new(); - let res = match client - .post(format!("{}/api/v1/servers/register-name", ctx.url)) - .bearer_auth(&ctx.token) - .json(&json!({ - "serverId": ctx.server_id, - "serverName": server_name, - })) - .send() - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "failed to call bootstrap register-name"); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "failed to reach bootstrap server"})), - ) - .into_response(); - } - }; - - let status = res.status(); - let body_json: serde_json::Value = res.json().await.unwrap_or_default(); - - if status.is_success() { - // Mark as registered in app_config - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_name_registered', '\"true\"') - ON CONFLICT (key) DO UPDATE SET value = '\"true\"'", - ) - .execute(state.db.pool()) - .await; - } - - ( - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(body_json), - ) - .into_response() -} - -// ── Recover server name ────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RecoverNameRequest { - server_name: String, - recovery_phrase: String, -} - -async fn recover_name( - State(state): State>, - _: crate::middleware::RequireApiKey, - Json(body): Json, -) -> impl IntoResponse { - let ctx = match bootstrap_context(&state).await { - Ok(ctx) => ctx, - Err(e) => return e.into_response(), - }; - - let client = reqwest::Client::new(); - let res = match client - .post(format!("{}/api/v1/servers/recover-name", ctx.url)) - .bearer_auth(&ctx.token) - .json(&json!({ - "serverName": body.server_name, - "recoveryPhrase": body.recovery_phrase, - "newServerId": ctx.server_id, - })) - .send() - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "failed to call bootstrap recover-name"); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "failed to reach bootstrap server"})), - ) - .into_response(); - } - }; - - let status = res.status(); - let body_json: serde_json::Value = res.json().await.unwrap_or_default(); - - if status.is_success() { - // Mark as registered in app_config - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_name_registered', '\"true\"') - ON CONFLICT (key) DO UPDATE SET value = '\"true\"'", - ) - .execute(state.db.pool()) - .await; - } - - ( - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(body_json), - ) - .into_response() -} - -// ── Bootstrap registration status ──────────────────────────────────────────── - -async fn bootstrap_status( - State(state): State>, - _: crate::middleware::RequireApiKey, -) -> impl IntoResponse { - let config = state.config.load(); - let enabled = config.bootstrap.enabled && config.bootstrap.url.is_some(); - - let name_registered = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'bootstrap_name_registered'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(|s| s == "true")) - .unwrap_or(false); - - Json(json!({ - "enabled": enabled, - "nameRegistered": name_registered, - "serverName": config.general.instance_name, - })) -} - -// ── Check name availability (proxy to bootstrap) ──────────────────────────── - -async fn check_name( - State(state): State>, - _: crate::middleware::RequireApiKey, - axum::extract::Path(name): axum::extract::Path, -) -> impl IntoResponse { - let ctx = match bootstrap_context(&state).await { - Ok(ctx) => ctx, - Err(e) => return e.into_response(), - }; - - let client = reqwest::Client::new(); - let res = match client - .get(format!("{}/api/v1/servers/check-name/{}", ctx.url, name)) - .send() - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "failed to call bootstrap check-name"); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "failed to reach bootstrap server"})), - ) - .into_response(); - } - }; - - let status = res.status(); - let body_json: serde_json::Value = res.json().await.unwrap_or_default(); - - ( - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(body_json), - ) - .into_response() -} - -// ── Check port forward (proxy to bootstrap) ────────────────────────────────── - -async fn check_port( - State(state): State>, - _: crate::middleware::RequireApiKey, -) -> impl IntoResponse { - let ctx = match bootstrap_context(&state).await { - Ok(ctx) => ctx, - Err(e) => return e.into_response(), - }; - - let client = reqwest::Client::new(); - let res = match client - .post(format!("{}/api/v1/servers/check-port", ctx.url)) - .bearer_auth(&ctx.token) - .json(&json!({ - "serverId": ctx.server_id, - })) - .send() - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "failed to call bootstrap check-port"); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "failed to reach bootstrap server"})), - ) - .into_response(); - } - }; - - let status = res.status(); - let body_json: serde_json::Value = res.json().await.unwrap_or_default(); - - ( - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(body_json), - ) - .into_response() -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/admin/bootstrap/register-name", post(register_name)) - .route("/api/v1/admin/bootstrap/recover-name", post(recover_name)) - .route("/api/v1/admin/bootstrap/status", get(bootstrap_status)) - .route( - "/api/v1/admin/bootstrap/check-name/{name}", - get(check_name), - ) - .route( - "/api/v1/admin/bootstrap/check-port", - post(check_port), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/calendar.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/calendar.rs deleted file mode 100644 index 2b1056f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/calendar.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Debug, Deserialize)] -struct CalendarParams { - start: Option, - end: Option, -} - -#[derive(Debug, sqlx::FromRow)] -struct CalendarRow { - episode_id: i64, - series_id: i64, - series_title: String, - season_number: i32, - episode_number: i32, - episode_title: Option, - air_date_utc: Option>, - has_file: bool, - monitored: bool, - images: Option, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct CalendarEntry { - episode_id: i64, - series_id: i64, - series_title: String, - season_number: i32, - episode_number: i32, - episode_title: Option, - air_date_utc: Option>, - has_file: bool, - monitored: bool, - poster_url: Option, -} - -async fn get_calendar( - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Default: next 14 days if no params - let start = params - .start - .unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string()); - let end = params.end.unwrap_or_else(|| { - (chrono::Utc::now() + chrono::Duration::days(14)) - .format("%Y-%m-%d") - .to_string() - }); - - let rows = sqlx::query_as::<_, CalendarRow>( - "SELECT e.id as episode_id, e.series_id, s.title as series_title, - e.season_number, e.episode_number, e.title as episode_title, - e.air_date_utc, (e.episode_file_id IS NOT NULL) as has_file, - e.monitored, s.images - FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.air_date_utc >= $1::timestamptz - AND e.air_date_utc <= $2::timestamptz - AND s.monitored = true - ORDER BY e.air_date_utc", - ) - .bind(&start) - .bind(&end) - .fetch_all(pool) - .await; - - match rows { - Ok(rows) => { - let entries: Vec = rows - .into_iter() - .map(|r| { - let poster_url = super::extract_image_url(&r.images, "poster"); - CalendarEntry { - episode_id: r.episode_id, - series_id: r.series_id, - series_title: r.series_title, - season_number: r.season_number, - episode_number: r.episode_number, - episode_title: r.episode_title, - air_date_utc: r.air_date_utc, - has_file: r.has_file, - monitored: r.monitored, - poster_url, - } - }) - .collect(); - Json(entries).into_response() - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(), - } -} - -pub fn router() -> Router> { - Router::new().route("/api/v1/calendar", get(get_calendar)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/discover.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/discover.rs deleted file mode 100644 index 404c1eb4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/discover.rs +++ /dev/null @@ -1,774 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use stackarr_core::models::discover::{ - CreateDiscoverSliderInput, DiscoverSlider, ReorderSlidersInput, UpdateDiscoverSliderInput, -}; -use stackarr_metadata::{DiscoverFilters, TmdbClient}; - -use crate::AppState; - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/// Resolve the TMDB API key from env or database. -async fn resolve_tmdb_key(state: &AppState) -> Option { - if let Ok(key) = std::env::var("STACKARR_TMDB_API_KEY") { - if !key.is_empty() { - return Some(key); - } - } - let pool = state.db.pool(); - let val: Option = sqlx::query_scalar( - "SELECT value FROM app_config WHERE key = 'tmdb_api_key'", - ) - .fetch_optional(pool) - .await - .ok() - .flatten(); - val.and_then(|v| v.as_str().map(|s| s.to_string())) - .filter(|s| !s.is_empty()) -} - -fn no_tmdb_key() -> impl IntoResponse { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured"})), - ) -} - -fn tmdb_error(e: impl std::fmt::Display) -> impl IntoResponse { - tracing::error!(error = %e, "TMDB request failed"); - ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "TMDB request failed"})), - ) -} - -// ── Trending ──────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct TrendingQuery { - media_type: Option, - time_window: Option, - page: Option, - language: Option, -} - -async fn get_trending( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - let media_type = query.media_type.as_deref().unwrap_or("all"); - let time_window = query.time_window.as_deref().unwrap_or("day"); - match client - .get_trending(media_type, time_window, query.page, query.language.as_deref()) - .await - { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -// ── Discover Movies ───────────────────────────────────────────────────────── - -async fn discover_movies( - State(state): State>, - Query(filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client.discover_movies(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -#[derive(Deserialize)] -struct GenrePathParam { - genre_id: i64, -} - -async fn discover_movies_by_genre( - State(state): State>, - Path(params): Path, - Query(mut filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - filters.with_genres = Some(params.genre_id.to_string()); - let client = TmdbClient::new(key); - match client.discover_movies(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -#[derive(Deserialize)] -struct StudioPathParam { - studio_id: i64, -} - -async fn discover_movies_by_studio( - State(state): State>, - Path(params): Path, - Query(mut filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - filters.with_companies = Some(params.studio_id.to_string()); - let client = TmdbClient::new(key); - match client.discover_movies(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn discover_movies_upcoming( - State(state): State>, - Query(mut filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); - filters.primary_release_date_gte = Some(today); - if filters.sort_by.is_none() { - filters.sort_by = Some("popularity.desc".to_string()); - } - let client = TmdbClient::new(key); - match client.discover_movies(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -// ── Discover TV ───────────────────────────────────────────────────────────── - -async fn discover_tv( - State(state): State>, - Query(filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client.discover_tv(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn discover_tv_by_genre( - State(state): State>, - Path(params): Path, - Query(mut filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - filters.with_genres = Some(params.genre_id.to_string()); - let client = TmdbClient::new(key); - match client.discover_tv(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -#[derive(Deserialize)] -struct NetworkPathParam { - network_id: i64, -} - -async fn discover_tv_by_network( - State(state): State>, - Path(params): Path, - Query(mut filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - filters.with_networks = Some(params.network_id.to_string()); - let client = TmdbClient::new(key); - match client.discover_tv(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn discover_tv_upcoming( - State(state): State>, - Query(mut filters): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); - filters.first_air_date_gte = Some(today); - if filters.sort_by.is_none() { - filters.sort_by = Some("popularity.desc".to_string()); - } - let client = TmdbClient::new(key); - match client.discover_tv(&filters).await { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -// ── Recommendations & Similar ─────────────────────────────────────────────── - -#[derive(Deserialize)] -struct MediaIdParam { - id: i64, -} - -#[derive(Deserialize)] -struct RecQuery { - page: Option, - language: Option, -} - -async fn movie_recommendations( - State(state): State>, - Path(params): Path, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client - .get_movie_recommendations(params.id, query.page, query.language.as_deref()) - .await - { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn movie_similar( - State(state): State>, - Path(params): Path, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client - .get_movie_similar(params.id, query.page, query.language.as_deref()) - .await - { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn tv_recommendations( - State(state): State>, - Path(params): Path, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client - .get_tv_recommendations(params.id, query.page, query.language.as_deref()) - .await - { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn tv_similar( - State(state): State>, - Path(params): Path, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client - .get_tv_similar(params.id, query.page, query.language.as_deref()) - .await - { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -// ── Genres ─────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct LangQuery { - language: Option, -} - -async fn get_movie_genres( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client.get_movie_genres(query.language.as_deref()).await { - Ok(genres) => Json(genres).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn get_tv_genres( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client.get_tv_genres(query.language.as_deref()).await { - Ok(genres) => Json(genres).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn get_languages(State(state): State>) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client.get_languages().await { - Ok(languages) => Json(languages).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -// ── Keywords ──────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct KeywordPathParam { - keyword_id: i64, -} - -async fn get_keyword( - State(state): State>, - Path(params): Path, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client.get_keyword(params.keyword_id).await { - Ok(kw) => Json(kw).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -async fn get_movies_by_keyword( - State(state): State>, - Path(params): Path, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - match client - .get_movies_by_keyword(params.keyword_id, query.page, query.language.as_deref()) - .await - { - Ok(results) => Json(results).into_response(), - Err(e) => tmdb_error(e).into_response(), - } -} - -// ── Discover Sliders CRUD ─────────────────────────────────────────────────── - -async fn list_sliders(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - match sqlx::query_as::<_, DiscoverSlider>( - "SELECT id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at \ - FROM discover_sliders ORDER BY display_order ASC", - ) - .fetch_all(pool) - .await - { - Ok(sliders) => Json(sliders).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list discover sliders"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn create_slider( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Get the next display_order - let max_order: Option<(i32,)> = - sqlx::query_as("SELECT COALESCE(MAX(display_order), 0) FROM discover_sliders") - .fetch_optional(pool) - .await - .ok() - .flatten(); - let next_order = max_order.map(|r| r.0 + 1).unwrap_or(1); - - let slider_type = serde_json::to_value(&input.slider_type) - .ok() - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default(); - - match sqlx::query_as::<_, DiscoverSlider>( - "INSERT INTO discover_sliders (slider_type, display_order, is_built_in, enabled, title, custom_data) \ - VALUES ($1, $2, false, $3, $4, $5) \ - RETURNING id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at", - ) - .bind(&slider_type) - .bind(next_order) - .bind(input.enabled.unwrap_or(true)) - .bind(&input.title) - .bind(&input.custom_data) - .fetch_one(pool) - .await - { - Ok(slider) => (StatusCode::CREATED, Json(slider)).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to create discover slider"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn update_slider( - State(state): State>, - Path(slider_id): Path, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Build dynamic update - let existing = sqlx::query_as::<_, DiscoverSlider>( - "SELECT id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at \ - FROM discover_sliders WHERE id = $1", - ) - .bind(slider_id) - .fetch_optional(pool) - .await; - - let existing = match existing { - Ok(Some(s)) => s, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, slider_id, "failed to fetch discover slider for update"); - return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(); - } - }; - - let new_title = input.title.or(existing.title); - let new_enabled = input.enabled.unwrap_or(existing.enabled); - let new_custom_data = if input.custom_data.is_some() { - input.custom_data - } else { - existing.custom_data - }; - - match sqlx::query_as::<_, DiscoverSlider>( - "UPDATE discover_sliders SET title = $1, enabled = $2, custom_data = $3, updated_at = NOW() \ - WHERE id = $4 \ - RETURNING id, slider_type, display_order, is_built_in, enabled, title, custom_data, created_at, updated_at", - ) - .bind(&new_title) - .bind(new_enabled) - .bind(&new_custom_data) - .bind(slider_id) - .fetch_one(pool) - .await - { - Ok(slider) => Json(slider).into_response(), - Err(e) => { - tracing::error!(error = %e, slider_id, "failed to update discover slider"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn delete_slider( - State(state): State>, - Path(slider_id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Don't allow deleting built-in sliders - let is_built_in: Option<(bool,)> = - sqlx::query_as("SELECT is_built_in FROM discover_sliders WHERE id = $1") - .bind(slider_id) - .fetch_optional(pool) - .await - .ok() - .flatten(); - - match is_built_in { - Some((true,)) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "Cannot delete built-in sliders. Disable them instead."})), - ) - .into_response(); - } - None => return StatusCode::NOT_FOUND.into_response(), - _ => {} - } - - match sqlx::query("DELETE FROM discover_sliders WHERE id = $1") - .bind(slider_id) - .execute(pool) - .await - { - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(e) => { - tracing::error!(error = %e, slider_id, "failed to delete discover slider"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn reorder_sliders( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - for (idx, slider_id) in input.slider_ids.iter().enumerate() { - let order = (idx + 1) as i32; - if let Err(e) = - sqlx::query("UPDATE discover_sliders SET display_order = $1, updated_at = NOW() WHERE id = $2") - .bind(order) - .bind(slider_id) - .execute(pool) - .await - { - tracing::error!(error = %e, slider_id, "failed to reorder discover slider"); - return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(); - } - } - - // Return the updated list - list_sliders(State(state)).await.into_response() -} - -async fn reset_sliders(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - // Delete custom sliders, re-enable and reorder built-ins - if let Err(e) = sqlx::query("DELETE FROM discover_sliders WHERE is_built_in = false") - .execute(pool) - .await - { - tracing::error!(error = %e, "failed to reset discover sliders"); - return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(); - } - - // Reset built-in order and enabled state - let defaults = [ - ("trending", 1), - ("popular_movies", 2), - ("popular_tv", 3), - ("upcoming_movies", 4), - ("upcoming_tv", 5), - ("recently_added", 6), - ("movie_genres", 7), - ("tv_genres", 8), - ]; - - for (slider_type, order) in defaults { - let _ = sqlx::query( - "UPDATE discover_sliders SET display_order = $1, enabled = true, updated_at = NOW() \ - WHERE slider_type = $2 AND is_built_in = true", - ) - .bind(order) - .bind(slider_type) - .execute(pool) - .await; - } - - list_sliders(State(state)).await.into_response() -} - -// ── Search (enriched with library/request status) ──────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchQuery { - q: String, - #[serde(rename = "type")] - media_type: Option, -} - -async fn discover_search( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let Some(key) = resolve_tmdb_key(&state).await else { - return no_tmdb_key().into_response(); - }; - let client = TmdbClient::new(key); - let pool = state.db.pool(); - - let media_type = query.media_type.as_deref().unwrap_or("movie"); - - if media_type == "series" || media_type == "tv" { - let results = match client.search_series(&query.q, None).await { - Ok(r) => r, - Err(e) => return tmdb_error(e).into_response(), - }; - // Enrich with library/request status - let mut enriched = Vec::with_capacity(results.results.len()); - for item in &results.results { - let in_library: bool = sqlx::query_as::<_, (i64,)>( - "SELECT id FROM series WHERE tmdb_id = $1", - ) - .bind(item.id) - .fetch_optional(pool) - .await - .ok() - .flatten() - .is_some(); - - let request_status: Option = sqlx::query_scalar( - "SELECT status FROM media_requests WHERE tmdb_id = $1 AND media_type = 'series'", - ) - .bind(item.id) - .fetch_optional(pool) - .await - .ok() - .flatten(); - - enriched.push(json!({ - "id": item.id, - "name": item.name, - "overview": item.overview, - "firstAirDate": item.first_air_date, - "posterPath": item.poster_path, - "backdropPath": item.backdrop_path, - "voteAverage": item.vote_average, - "mediaType": "series", - "inLibrary": in_library, - "requestStatus": request_status, - })); - } - Json(json!({ - "page": results.page, - "totalPages": results.total_pages, - "totalResults": results.total_results, - "results": enriched, - })) - .into_response() - } else { - let results = match client.search_movie(&query.q, None).await { - Ok(r) => r, - Err(e) => return tmdb_error(e).into_response(), - }; - let mut enriched = Vec::with_capacity(results.results.len()); - for item in &results.results { - let in_library: bool = sqlx::query_as::<_, (i64,)>( - "SELECT id FROM movies WHERE tmdb_id = $1", - ) - .bind(item.id) - .fetch_optional(pool) - .await - .ok() - .flatten() - .is_some(); - - let request_status: Option = sqlx::query_scalar( - "SELECT status FROM media_requests WHERE tmdb_id = $1 AND media_type = 'movie'", - ) - .bind(item.id) - .fetch_optional(pool) - .await - .ok() - .flatten(); - - enriched.push(json!({ - "id": item.id, - "title": item.title, - "overview": item.overview, - "releaseDate": item.release_date, - "posterPath": item.poster_path, - "backdropPath": item.backdrop_path, - "voteAverage": item.vote_average, - "mediaType": "movie", - "inLibrary": in_library, - "requestStatus": request_status, - })); - } - Json(json!({ - "page": results.page, - "totalPages": results.total_pages, - "totalResults": results.total_results, - "results": enriched, - })) - .into_response() - } -} - -// ── Router ────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - // Search (enriched) - .route("/api/v1/discover/search", get(discover_search)) - // Trending - .route("/api/v1/discover/trending", get(get_trending)) - // Movies discovery - .route("/api/v1/discover/movies", get(discover_movies)) - .route("/api/v1/discover/movies/upcoming", get(discover_movies_upcoming)) - .route("/api/v1/discover/movies/genre/{genre_id}", get(discover_movies_by_genre)) - .route("/api/v1/discover/movies/studio/{studio_id}", get(discover_movies_by_studio)) - // TV discovery - .route("/api/v1/discover/tv", get(discover_tv)) - .route("/api/v1/discover/tv/upcoming", get(discover_tv_upcoming)) - .route("/api/v1/discover/tv/genre/{genre_id}", get(discover_tv_by_genre)) - .route("/api/v1/discover/tv/network/{network_id}", get(discover_tv_by_network)) - // Recommendations & similar - .route("/api/v1/discover/movies/{id}/recommendations", get(movie_recommendations)) - .route("/api/v1/discover/movies/{id}/similar", get(movie_similar)) - .route("/api/v1/discover/tv/{id}/recommendations", get(tv_recommendations)) - .route("/api/v1/discover/tv/{id}/similar", get(tv_similar)) - // Genres & languages - .route("/api/v1/discover/genres/movie", get(get_movie_genres)) - .route("/api/v1/discover/genres/tv", get(get_tv_genres)) - .route("/api/v1/discover/languages", get(get_languages)) - // Keywords - .route("/api/v1/discover/keyword/{keyword_id}", get(get_keyword)) - .route("/api/v1/discover/keyword/{keyword_id}/movies", get(get_movies_by_keyword)) - // Slider management - .route("/api/v1/discover/sliders", get(list_sliders).post(reorder_sliders)) - .route("/api/v1/discover/sliders/add", post(create_slider)) - .route("/api/v1/discover/sliders/reset", post(reset_sliders)) - .route( - "/api/v1/discover/sliders/{slider_id}", - put(update_slider).delete(delete_slider), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/downloadclients.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/downloadclients.rs deleted file mode 100644 index 5681b821..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/downloadclients.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct DownloadClientResponse { - id: i32, - name: String, - client_type: String, - protocol: String, - config: serde_json::Value, - enabled: bool, - priority: i32, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateDownloadClientRequest { - name: String, - client_type: String, - protocol: String, - config: serde_json::Value, - enabled: Option, - priority: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateDownloadClientRequest { - name: Option, - client_type: Option, - protocol: Option, - config: Option, - enabled: Option, - priority: Option, -} - -async fn list_download_clients(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, DownloadClientResponse>( - "SELECT id, name, client_type, protocol, config, enabled, priority - FROM download_clients ORDER BY priority, id", - ) - .fetch_all(pool) - .await - { - Ok(clients) => Json(clients).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list download clients"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn create_download_client( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if body.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "name cannot be empty"})), - ) - .into_response(); - } - - let enabled = body.enabled.unwrap_or(true); - let priority = body.priority.unwrap_or(1); - - match sqlx::query_as::<_, DownloadClientResponse>( - "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, name, client_type, protocol, config, enabled, priority", - ) - .bind(body.name.trim()) - .bind(&body.client_type) - .bind(&body.protocol) - .bind(&body.config) - .bind(enabled) - .bind(priority) - .fetch_one(pool) - .await - { - Ok(client) => (StatusCode::CREATED, Json(json!(client))).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to create download client"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn update_download_client( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, DownloadClientResponse>( - "UPDATE download_clients SET - name = COALESCE($1, name), - client_type = COALESCE($2, client_type), - protocol = COALESCE($3, protocol), - config = COALESCE($4, config), - enabled = COALESCE($5, enabled), - priority = COALESCE($6, priority) - WHERE id = $7 - RETURNING id, name, client_type, protocol, config, enabled, priority", - ) - .bind(body.name.as_deref().map(str::trim)) - .bind(&body.client_type) - .bind(&body.protocol) - .bind(&body.config) - .bind(body.enabled) - .bind(body.priority) - .bind(id as i32) - .fetch_optional(pool) - .await - { - Ok(Some(client)) => Json(json!(client)).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "download client not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to update download client"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_download_client( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query("DELETE FROM download_clients WHERE id = $1") - .bind(id as i32) - .execute(pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "download client not found"})), - ) - .into_response() - } else { - StatusCode::NO_CONTENT.into_response() - } - } - Err(e) => { - tracing::error!(error = %e, "failed to delete download client"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn test_download_client( - State(_state): State>, - Path(_id): Path, -) -> impl IntoResponse { - // Stub: just return ok for now - Json(json!({ - "success": true, - "message": "connection test passed" - })) - .into_response() -} - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/downloadclient", - get(list_download_clients).post(create_download_client), - ) - .route( - "/api/v1/downloadclient/{id}", - axum::routing::put(update_download_client).delete(delete_download_client), - ) - .route( - "/api/v1/downloadclient/{id}/test", - post(test_download_client), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/episodes.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/episodes.rs deleted file mode 100644 index c485d3c0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/episodes.rs +++ /dev/null @@ -1,316 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, put}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use stackarr_core::models::media::MediaFile; - -use crate::AppState; - -#[derive(Debug, sqlx::FromRow)] -#[expect(dead_code)] -struct EpisodeRow { - id: i64, - series_id: i64, - season_number: i32, - episode_number: i32, - absolute_number: Option, - scene_season_number: Option, - scene_episode_number: Option, - scene_absolute_number: Option, - title: Option, - overview: Option, - air_date: Option, - air_date_utc: Option>, - runtime: Option, - monitored: bool, - episode_file_id: Option, - last_search_time: Option>, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct EpisodeResponse { - id: i64, - series_id: i64, - season_number: i32, - episode_number: i32, - absolute_number: Option, - scene_season_number: Option, - scene_episode_number: Option, - scene_absolute_number: Option, - title: Option, - overview: Option, - air_date: Option, - air_date_utc: Option>, - runtime: Option, - monitored: bool, - has_file: bool, - episode_file: Option, -} - -fn enrich_episode(ep: EpisodeRow, files: &HashMap) -> EpisodeResponse { - let has_file = ep.episode_file_id.is_some(); - let episode_file = ep.episode_file_id.and_then(|fid| files.get(&fid).cloned()); - EpisodeResponse { - id: ep.id, - series_id: ep.series_id, - season_number: ep.season_number, - episode_number: ep.episode_number, - absolute_number: ep.absolute_number, - scene_season_number: ep.scene_season_number, - scene_episode_number: ep.scene_episode_number, - scene_absolute_number: ep.scene_absolute_number, - title: ep.title, - overview: ep.overview, - air_date: ep.air_date, - air_date_utc: ep.air_date_utc, - runtime: ep.runtime, - monitored: ep.monitored, - has_file, - episode_file, - } -} - -async fn fetch_media_files( - pool: &sqlx::PgPool, - file_ids: &[i64], -) -> HashMap { - if file_ids.is_empty() { - return HashMap::new(); - } - sqlx::query_as::<_, MediaFile>("SELECT * FROM media_files WHERE id = ANY($1)") - .bind(file_ids) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .map(|f| (f.id, f)) - .collect() -} - -/// GET /api/v1/series/{seriesId}/episodes -async fn list_episodes_for_series( - State(state): State>, - Path(series_id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - let rows = sqlx::query_as::<_, EpisodeRow>( - "SELECT id, series_id, season_number, episode_number, absolute_number, - scene_season_number, scene_episode_number, scene_absolute_number, - title, overview, air_date, air_date_utc, runtime, monitored, - episode_file_id, last_search_time - FROM episodes - WHERE series_id = $1 - ORDER BY season_number, episode_number", - ) - .bind(series_id) - .fetch_all(pool) - .await; - - match rows { - Ok(episodes) => { - let file_ids: Vec = episodes.iter().filter_map(|e| e.episode_file_id).collect(); - let files = fetch_media_files(pool, &file_ids).await; - let responses: Vec = episodes - .into_iter() - .map(|e| enrich_episode(e, &files)) - .collect(); - Json(responses).into_response() - } - Err(e) => { - tracing::error!(error = %e, series_id, "failed to list episodes for series"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -/// GET /api/v1/episode/{id} -async fn get_episode( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - let row = sqlx::query_as::<_, EpisodeRow>( - "SELECT id, series_id, season_number, episode_number, absolute_number, - scene_season_number, scene_episode_number, scene_absolute_number, - title, overview, air_date, air_date_utc, runtime, monitored, - episode_file_id, last_search_time - FROM episodes - WHERE id = $1", - ) - .bind(id) - .fetch_optional(pool) - .await; - - match row { - Ok(Some(ep)) => { - let file_ids: Vec = ep.episode_file_id.into_iter().collect(); - let files = fetch_media_files(pool, &file_ids).await; - Json(enrich_episode(ep, &files)).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "episode not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, id, "failed to fetch episode"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateEpisodeRequest { - monitored: Option, -} - -/// PUT /api/v1/episode/{id} -async fn update_episode( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if let Some(monitored) = body.monitored { - let result = sqlx::query( - "UPDATE episodes SET monitored = $1 WHERE id = $2", - ) - .bind(monitored) - .bind(id) - .execute(pool) - .await; - - match result { - Ok(r) if r.rows_affected() == 0 => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "episode not found"})), - ) - .into_response(); - } - Ok(_) => {} - Err(e) => { - tracing::error!(error = %e, id, "failed to update episode"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - } - - // Return the updated episode - let row = sqlx::query_as::<_, EpisodeRow>( - "SELECT id, series_id, season_number, episode_number, absolute_number, - scene_season_number, scene_episode_number, scene_absolute_number, - title, overview, air_date, air_date_utc, runtime, monitored, - episode_file_id, last_search_time - FROM episodes - WHERE id = $1", - ) - .bind(id) - .fetch_optional(pool) - .await; - - match row { - Ok(Some(ep)) => { - let file_ids: Vec = ep.episode_file_id.into_iter().collect(); - let files = fetch_media_files(pool, &file_ids).await; - Json(enrich_episode(ep, &files)).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "episode not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, id, "failed to fetch updated episode"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct BulkMonitorRequest { - episode_ids: Vec, - monitored: bool, -} - -/// PUT /api/v1/episode/monitor -async fn bulk_monitor( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if body.episode_ids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "episode_ids must not be empty"})), - ) - .into_response(); - } - - let result = sqlx::query( - "UPDATE episodes SET monitored = $1 WHERE id = ANY($2)", - ) - .bind(body.monitored) - .bind(&body.episode_ids) - .execute(pool) - .await; - - match result { - Ok(r) => Json(json!({ - "updated": r.rows_affected(), - "monitored": body.monitored, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to bulk update episode monitoring"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/series/{seriesId}/episodes", - get(list_episodes_for_series), - ) - .route( - "/api/v1/episode/{id}", - get(get_episode).put(update_episode), - ) - .route("/api/v1/episode/monitor", put(bulk_monitor)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/general.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/general.rs deleted file mode 100644 index a53bd150..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/general.rs +++ /dev/null @@ -1,259 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; - -use crate::AppState; - -#[derive(Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct GeneralConfig { - instance_name: String, - auth_method: String, - grab_strategy: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateGeneralConfig { - instance_name: Option, - auth_method: Option, - grab_strategy: Option, -} - -async fn get_general(State(state): State>) -> impl IntoResponse { - let rows = sqlx::query_as::<_, (String, serde_json::Value)>( - "SELECT key, value FROM app_config WHERE key IN ('instance_name', 'auth_method', 'grab_strategy')", - ) - .fetch_all(state.db.pool()) - .await - .unwrap_or_default(); - - let mut config = GeneralConfig { - instance_name: String::new(), - auth_method: "none".to_string(), - grab_strategy: "best_quality".to_string(), - }; - - for (key, value) in rows { - let s = value - .as_str() - .map(|s| s.to_string()) - .unwrap_or_else(|| value.to_string().trim_matches('"').to_string()); - match key.as_str() { - "instance_name" => config.instance_name = s, - "auth_method" => config.auth_method = s, - "grab_strategy" => config.grab_strategy = s, - _ => {} - } - } - - Json(config) -} - -async fn put_general( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if let Some(name) = &body.instance_name { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('instance_name', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(name)) - .execute(pool) - .await; - } - - if let Some(method) = &body.auth_method { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('auth_method', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(method)) - .execute(pool) - .await; - } - - if let Some(strategy) = &body.grab_strategy { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('grab_strategy', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(strategy)) - .execute(pool) - .await; - } - - Json(serde_json::json!({"success": true})) -} - -// --------------------------------------------------------------------------- -// Bootstrap config -// --------------------------------------------------------------------------- - -#[derive(Serialize, Default)] -#[serde(rename_all = "camelCase")] -struct BootstrapConfigResponse { - enabled: bool, - url: String, - token: String, - advertise_port: Option, - upnp_enabled: bool, - discovery_name: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateBootstrapConfig { - enabled: Option, - url: Option, - token: Option, - advertise_port: Option>, - upnp_enabled: Option, - discovery_name: Option, -} - -async fn get_bootstrap_config(State(state): State>) -> impl IntoResponse { - let rows = sqlx::query_as::<_, (String, serde_json::Value)>( - "SELECT key, value FROM app_config WHERE key IN ('bootstrap_enabled', 'bootstrap_url', 'bootstrap_token', 'bootstrap_advertise_port', 'bootstrap_upnp_enabled', 'discovery_name')", - ) - .fetch_all(state.db.pool()) - .await - .unwrap_or_default(); - - // Fall back to TOML config values - let toml_config = state.config.load(); - let mut config = BootstrapConfigResponse { - enabled: toml_config.bootstrap.enabled, - url: toml_config.bootstrap.url.clone().unwrap_or_default(), - token: toml_config.bootstrap.token.clone().unwrap_or_default(), - advertise_port: toml_config.bootstrap.advertise_port, - upnp_enabled: toml_config.bootstrap.upnp_enabled, - discovery_name: String::new(), - }; - - // Override with DB values where present - for (key, value) in &rows { - match key.as_str() { - "bootstrap_enabled" => { - if let Some(b) = value.as_bool() { - config.enabled = b; - } - } - "bootstrap_url" => { - if let Some(s) = value.as_str() { - config.url = s.to_string(); - } - } - "bootstrap_token" => { - if let Some(s) = value.as_str() { - config.token = s.to_string(); - } - } - "bootstrap_advertise_port" => { - if value.is_null() { - config.advertise_port = None; - } else if let Some(n) = value.as_u64() { - config.advertise_port = Some(n as u16); - } - } - "bootstrap_upnp_enabled" => { - if let Some(b) = value.as_bool() { - config.upnp_enabled = b; - } - } - "discovery_name" => { - if let Some(s) = value.as_str() { - config.discovery_name = s.to_string(); - } - } - _ => {} - } - } - - Json(config) -} - -async fn put_bootstrap_config( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if let Some(enabled) = body.enabled { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_enabled', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(enabled)) - .execute(pool) - .await; - } - - if let Some(url) = &body.url { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_url', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(url)) - .execute(pool) - .await; - } - - if let Some(token) = &body.token { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_token', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(token)) - .execute(pool) - .await; - } - - if let Some(port) = body.advertise_port { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_advertise_port', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(port)) - .execute(pool) - .await; - } - - if let Some(upnp) = body.upnp_enabled { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('bootstrap_upnp_enabled', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(upnp)) - .execute(pool) - .await; - } - - if let Some(name) = &body.discovery_name { - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('discovery_name', $1::jsonb) - ON CONFLICT (key) DO UPDATE SET value = $1::jsonb", - ) - .bind(serde_json::json!(name)) - .execute(pool) - .await; - } - - Json(serde_json::json!({"success": true})) -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/config/general", get(get_general).put(put_general)) - .route( - "/api/v1/config/bootstrap", - get(get_bootstrap_config).put(put_bootstrap_config), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/health.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/health.rs deleted file mode 100644 index ace633db..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/health.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::Serialize; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, -} - -async fn health_check() -> Json { - Json(HealthResponse { status: "ok" }) -} - -async fn system_health(State(state): State>) -> impl IntoResponse { - let mut checks = serde_json::Map::new(); - let mut overall = "ok"; - - // 1. Database connectivity - match sqlx::query("SELECT 1").execute(state.db.pool()).await { - Ok(_) => { - checks.insert("database".into(), json!("ok")); - } - Err(_) => { - checks.insert("database".into(), json!("unavailable")); - overall = "unhealthy"; - } - } - - // 2. Disk space on media library folders - let folder_rows: Vec<(String,)> = sqlx::query_as( - "SELECT path FROM media_library_folders", - ) - .fetch_all(state.db.pool()) - .await - .unwrap_or_default(); - - let mut disk_issues = Vec::new(); - for (path,) in &folder_rows { - let scan_path = std::path::Path::new(path); - if !scan_path.exists() { - disk_issues.push(format!("{path}: path does not exist")); - } else if let Ok(output) = std::process::Command::new("df") - .args(["--output=avail", "-B1", path]) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(avail_str) = stdout.lines().nth(1) { - if let Ok(avail) = avail_str.trim().parse::() { - // Warn if less than 1 GB free - if avail < 1_073_741_824 { - disk_issues.push(format!( - "{path}: low disk space ({} MB free)", - avail / 1_048_576 - )); - } - } - } - } - } - } - - if disk_issues.is_empty() { - checks.insert("diskSpace".into(), json!("ok")); - } else { - checks.insert("diskSpace".into(), json!({"warning": disk_issues})); - if overall == "ok" { - overall = "warning"; - } - } - - // 3. Embedded torrent engine - if state.modules.torrent_embedded { - if state.torrent_session.is_some() { - checks.insert("torrentEngine".into(), json!("ok")); - } else { - checks.insert("torrentEngine".into(), json!("not running")); - if overall == "ok" { - overall = "warning"; - } - } - } - - // 4. Embedded usenet engine - if state.modules.usenet_embedded { - if state.usenet_queue.is_some() { - checks.insert("usenetEngine".into(), json!("ok")); - } else { - checks.insert("usenetEngine".into(), json!("not running")); - if overall == "ok" { - overall = "warning"; - } - } - } - - // 5. Indexarr sidecar - if state.modules.indexarr_sidecar { - if let Some(ref client) = state.indexarr_client { - match client.health_check().await { - Ok(()) => { - checks.insert("indexarr".into(), json!("ok")); - } - Err(_) => { - checks.insert("indexarr".into(), json!("unreachable")); - if overall == "ok" { - overall = "warning"; - } - } - } - } else { - checks.insert("indexarr".into(), json!("not configured")); - } - } - - // 6. Indexer count - let indexer_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM indexers WHERE enabled = true", - ) - .fetch_one(state.db.pool()) - .await - .unwrap_or(0); - checks.insert("indexers".into(), json!({"enabled": indexer_count})); - - // 7. Download client count - let client_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM download_clients WHERE enabled = true", - ) - .fetch_one(state.db.pool()) - .await - .unwrap_or(0); - checks.insert("downloadClients".into(), json!({"enabled": client_count})); - - let status = match overall { - "unhealthy" => StatusCode::SERVICE_UNAVAILABLE, - _ => StatusCode::OK, - }; - - ( - status, - Json(json!({ - "status": overall, - "checks": serde_json::Value::Object(checks), - })), - ) - .into_response() -} - -/// GET /metrics — Prometheus-compatible metrics in text exposition format. -async fn prometheus_metrics(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - let series_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM series") - .fetch_one(pool).await.unwrap_or(0); - let movies_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM movies") - .fetch_one(pool).await.unwrap_or(0); - let episodes_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM episodes") - .fetch_one(pool).await.unwrap_or(0); - let queue_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM queue") - .fetch_one(pool).await.unwrap_or(0); - let history_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM history") - .fetch_one(pool).await.unwrap_or(0); - let indexers_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM indexers WHERE enabled = true") - .fetch_one(pool).await.unwrap_or(0); - let clients_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM download_clients WHERE enabled = true") - .fetch_one(pool).await.unwrap_or(0); - let blocklist_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocklist") - .fetch_one(pool).await.unwrap_or(0); - let media_files_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files") - .fetch_one(pool).await.unwrap_or(0); - let monitored_series: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM series WHERE monitored = true") - .fetch_one(pool).await.unwrap_or(0); - let monitored_movies: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM movies WHERE monitored = true") - .fetch_one(pool).await.unwrap_or(0); - - let body = format!( - "# HELP stackarr_series_total Total number of series\n\ - # TYPE stackarr_series_total gauge\n\ - stackarr_series_total {series_count}\n\ - # HELP stackarr_series_monitored Number of monitored series\n\ - # TYPE stackarr_series_monitored gauge\n\ - stackarr_series_monitored {monitored_series}\n\ - # HELP stackarr_movies_total Total number of movies\n\ - # TYPE stackarr_movies_total gauge\n\ - stackarr_movies_total {movies_count}\n\ - # HELP stackarr_movies_monitored Number of monitored movies\n\ - # TYPE stackarr_movies_monitored gauge\n\ - stackarr_movies_monitored {monitored_movies}\n\ - # HELP stackarr_episodes_total Total number of episodes\n\ - # TYPE stackarr_episodes_total gauge\n\ - stackarr_episodes_total {episodes_count}\n\ - # HELP stackarr_media_files_total Total number of media files on disk\n\ - # TYPE stackarr_media_files_total gauge\n\ - stackarr_media_files_total {media_files_count}\n\ - # HELP stackarr_queue_total Items currently in download queue\n\ - # TYPE stackarr_queue_total gauge\n\ - stackarr_queue_total {queue_count}\n\ - # HELP stackarr_history_total Total history events\n\ - # TYPE stackarr_history_total gauge\n\ - stackarr_history_total {history_count}\n\ - # HELP stackarr_blocklist_total Blocklisted releases\n\ - # TYPE stackarr_blocklist_total gauge\n\ - stackarr_blocklist_total {blocklist_count}\n\ - # HELP stackarr_indexers_enabled Enabled indexers\n\ - # TYPE stackarr_indexers_enabled gauge\n\ - stackarr_indexers_enabled {indexers_count}\n\ - # HELP stackarr_download_clients_enabled Enabled download clients\n\ - # TYPE stackarr_download_clients_enabled gauge\n\ - stackarr_download_clients_enabled {clients_count}\n" - ); - - ( - [( - axum::http::header::CONTENT_TYPE, - "text/plain; version=0.0.4; charset=utf-8", - )], - body, - ) -} - -pub fn router() -> Router> { - Router::new() - .route("/health", get(health_check)) - .route("/api/v1/system/health", get(system_health)) - .route("/metrics", get(prometheus_metrics)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/history.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/history.rs deleted file mode 100644 index 801e1aee..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/history.rs +++ /dev/null @@ -1,166 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -use stackarr_core::models::HistoryEvent; - -use crate::AppState; - -#[derive(Debug, Deserialize)] -struct PaginationParams { - #[serde(default = "default_page")] - page: i64, - #[serde(default = "default_page_size")] - page_size: i64, -} - -fn default_page() -> i64 { - 1 -} - -fn default_page_size() -> i64 { - 20 -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct PaginatedHistory { - page: i64, - page_size: i64, - total_records: i64, - records: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct HistoryResponse { - id: i64, - date: DateTime, - event_type: String, - source_title: String, - quality: serde_json::Value, - indexer: Option, - media_type: String, - series_id: Option, - movie_id: Option, - episode_id: Option, -} - -/// Resolve quality JSONB `{"quality": 18, ...}` to a named version `{"quality": "Bluray-2160p", ...}`. -fn resolve_quality(q: &serde_json::Value) -> serde_json::Value { - if let Some(obj) = q.as_object() { - if let Some(num) = obj.get("quality").and_then(|v| v.as_i64()) { - let name = stackarr_quality::quality_name(num as i32); - let mut resolved = obj.clone(); - resolved.insert("quality".to_string(), serde_json::Value::String(name.to_string())); - return serde_json::Value::Object(resolved); - } - } - // If it's already a string or unknown shape, pass through - q.clone() -} - -fn to_response( - event: HistoryEvent, - indexer_names: &HashMap, -) -> HistoryResponse { - let indexer = event - .indexer_id - .and_then(|id| indexer_names.get(&id).cloned()); - let media_type_str = format!("{:?}", event.media_type).to_lowercase(); - let event_type_str = format!("{:?}", event.event_type); - // Convert PascalCase enum variant to camelCase for frontend - let event_type_camel = { - let mut chars = event_type_str.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_lowercase().to_string() + chars.as_str(), - } - }; - - let is_series = media_type_str == "series"; - let is_movie = media_type_str == "movie"; - - HistoryResponse { - id: event.id, - date: event.occurred_at, - event_type: event_type_camel, - source_title: event.source_title, - quality: resolve_quality(&event.quality), - indexer, - media_type: media_type_str, - series_id: if is_series { Some(event.media_id) } else { None }, - movie_id: if is_movie { Some(event.media_id) } else { None }, - episode_id: event.episode_id, - } -} - -#[derive(sqlx::FromRow)] -struct IndexerRow { - id: i64, - name: String, -} - -async fn list_history( - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - let pool = state.db.pool(); - let offset = (params.page - 1) * params.page_size; - - // Get total count - let count_result: Result<(i64,), _> = - sqlx::query_as("SELECT COUNT(*) FROM history") - .fetch_one(pool) - .await; - - let total = match count_result { - Ok((c,)) => c, - Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - }; - - let (events_result, indexers_result) = tokio::join!( - sqlx::query_as::<_, HistoryEvent>( - "SELECT * FROM history ORDER BY occurred_at DESC LIMIT $1 OFFSET $2", - ) - .bind(params.page_size) - .bind(offset) - .fetch_all(pool), - sqlx::query_as::<_, IndexerRow>("SELECT id, name FROM indexers") - .fetch_all(pool), - ); - - let indexer_names: HashMap = indexers_result - .unwrap_or_default() - .into_iter() - .map(|r| (r.id, r.name)) - .collect(); - - match events_result { - Ok(records) => { - let responses: Vec = records - .into_iter() - .map(|e| to_response(e, &indexer_names)) - .collect(); - Json(PaginatedHistory { - page: params.page, - page_size: params.page_size, - total_records: total, - records: responses, - }) - .into_response() - } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -pub fn router() -> Router> { - Router::new().route("/api/v1/history", get(list_history)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/images.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/images.rs deleted file mode 100644 index aa89e7c7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/images.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode}; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::Router; -use sha2::{Digest, Sha256}; - -use crate::state::AppState; - -/// Allowed upstream domains for image proxying (SSRF prevention). -const ALLOWED_DOMAINS: &[&str] = &["image.tmdb.org", "artworks.thetvdb.com"]; - -fn cache_dir(state: &AppState) -> PathBuf { - let config = state.config.load(); - config.general.data_dir.join("image_cache") -} - -fn is_allowed_url(url: &str) -> bool { - if let Ok(parsed) = url::Url::parse(url) { - if let Some(host) = parsed.host_str() { - return ALLOWED_DOMAINS.iter().any(|d| host == *d || host.ends_with(&format!(".{d}"))); - } - } - false -} - -fn url_to_cache_path(cache_dir: &std::path::Path, url: &str) -> (PathBuf, PathBuf) { - let hash = format!("{:x}", Sha256::digest(url.as_bytes())); - let data_path = cache_dir.join(&hash); - let meta_path = cache_dir.join(format!("{hash}.meta")); - (data_path, meta_path) -} - -async fn proxy_image( - State(state): State>, - Path(url): Path, -) -> impl IntoResponse { - if !is_allowed_url(&url) { - return (StatusCode::FORBIDDEN, "domain not allowed").into_response(); - } - - let cache = cache_dir(&state); - let (data_path, meta_path) = url_to_cache_path(&cache, &url); - - // Cache hit — serve from disk - if data_path.exists() { - let content_type = tokio::fs::read_to_string(&meta_path) - .await - .unwrap_or_else(|_| "image/jpeg".to_string()); - - match tokio::fs::read(&data_path).await { - Ok(bytes) => { - let mut headers = HeaderMap::new(); - if let Ok(ct) = HeaderValue::from_str(&content_type) { - headers.insert("content-type", ct); - } - headers.insert("cache-control", HeaderValue::from_static("public, max-age=604800")); - headers.insert("x-cache", HeaderValue::from_static("HIT")); - return (StatusCode::OK, headers, bytes).into_response(); - } - Err(e) => { - tracing::warn!(error = %e, "failed to read cached image, re-fetching"); - // Fall through to fetch - } - } - } - - // Cache miss — fetch upstream - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(15)) - .build() - .unwrap_or_default(); - - let resp = match client.get(&url).send().await { - Ok(r) => r, - Err(e) => { - tracing::warn!(error = %e, url = %url, "image proxy fetch failed"); - return (StatusCode::BAD_GATEWAY, "upstream fetch failed").into_response(); - } - }; - - if !resp.status().is_success() { - return (StatusCode::BAD_GATEWAY, "upstream returned error").into_response(); - } - - let content_type = resp - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - - let bytes = match resp.bytes().await { - Ok(b) => b, - Err(e) => { - tracing::warn!(error = %e, "failed to read upstream response body"); - return (StatusCode::BAD_GATEWAY, "failed to read upstream").into_response(); - } - }; - - // Write to cache (best-effort, don't fail the request) - if let Err(e) = tokio::fs::create_dir_all(&cache).await { - tracing::warn!(error = %e, "failed to create image cache dir"); - } else { - let _ = tokio::fs::write(&data_path, &bytes).await; - let _ = tokio::fs::write(&meta_path, &content_type).await; - } - - let mut headers = HeaderMap::new(); - if let Ok(ct) = HeaderValue::from_str(&content_type) { - headers.insert("content-type", ct); - } - headers.insert("cache-control", HeaderValue::from_static("public, max-age=604800")); - headers.insert("x-cache", HeaderValue::from_static("MISS")); - (StatusCode::OK, headers, bytes.to_vec()).into_response() -} - -pub fn router() -> Router> { - Router::new().route("/api/v1/images/{*url}", get(proxy_image)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/importlists.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/importlists.rs deleted file mode 100644 index 34595931..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/importlists.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde_json::json; - -use stackarr_media::import_lists::{ - CreateImportListInput, ImportListService, UpdateImportListInput, -}; -use stackarr_metadata::TmdbClient; - -use crate::AppState; - -async fn list_all(State(state): State>) -> impl IntoResponse { - let svc = ImportListService::new(state.db.pool().clone()); - match svc.list().await { - Ok(list) => Json(list).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -async fn create( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let svc = ImportListService::new(state.db.pool().clone()); - match svc.create(input).await { - Ok(il) => (StatusCode::CREATED, Json(il)).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn update( - State(state): State>, - Path(id): Path, - Json(input): Json, -) -> impl IntoResponse { - let svc = ImportListService::new(state.db.pool().clone()); - match svc.update(id, input).await { - Ok(il) => Json(il).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn delete( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let svc = ImportListService::new(state.db.pool().clone()); - match svc.delete(id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -async fn sync_one( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let tmdb_client = match get_tmdb_client(&state).await { - Ok(c) => c, - Err(resp) => return resp, - }; - - let svc = ImportListService::new(state.db.pool().clone()); - match svc.sync(id, &tmdb_client).await { - Ok(result) => Json(result).into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": format!("Sync failed: {e}")})), - ) - .into_response(), - } -} - -async fn sync_all(State(state): State>) -> impl IntoResponse { - let tmdb_client = match get_tmdb_client(&state).await { - Ok(c) => c, - Err(resp) => return resp, - }; - - let svc = ImportListService::new(state.db.pool().clone()); - match svc.sync_all(&tmdb_client).await { - Ok(results) => Json(results).into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": format!("Sync all failed: {e}")})), - ) - .into_response(), - } -} - -/// Resolve a TMDB API key from env or DB and build a client. -async fn get_tmdb_client( - state: &AppState, -) -> Result { - let api_key = std::env::var("STACKARR_TMDB_API_KEY").ok(); - - let api_key = match api_key { - Some(key) if !key.is_empty() => key, - _ => { - let pool = state.db.pool(); - match sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'tmdb_api_key'", - ) - .fetch_optional(pool) - .await - { - Ok(Some(val)) => match val.as_str() { - Some(k) if !k.is_empty() => k.to_string(), - _ => { - return Err(( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured"})), - ) - .into_response()); - } - }, - _ => { - return Err(( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured"})), - ) - .into_response()); - } - } - } - }; - - Ok(TmdbClient::new(api_key)) -} - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/importlist", - get(list_all).post(create), - ) - .route( - "/api/v1/importlist/{id}", - axum::routing::put(update).delete(delete), - ) - .route("/api/v1/importlist/{id}/sync", post(sync_one)) - .route("/api/v1/importlist/sync", post(sync_all)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/indexarr.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/indexarr.rs deleted file mode 100644 index 97ee13fb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/indexarr.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde_json::json; - -use crate::AppState; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn not_enabled() -> Response { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "Indexarr sidecar not enabled"})), - ) - .into_response() -} - -/// Forward a GET request to Indexarr, injecting the API key. -async fn proxy_get( - base_url: &str, - api_key: &str, - path: &str, - params: &HashMap, -) -> Response { - let client = reqwest::Client::new(); - let url = format!("{base_url}{path}"); - let req = client - .get(&url) - .header("X-Api-Key", api_key) - .query(params); - - match req.send().await { - Ok(resp) => { - let status = StatusCode::from_u16(resp.status().as_u16()) - .unwrap_or(StatusCode::BAD_GATEWAY); - let body = resp.text().await.unwrap_or_default(); - (status, [("Content-Type", "application/json")], body).into_response() - } - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("failed to reach Indexarr: {e}")})), - ) - .into_response(), - } -} - -/// Forward a POST request (JSON body) to Indexarr, injecting the API key. -async fn proxy_post_json( - base_url: &str, - api_key: &str, - path: &str, - body: serde_json::Value, -) -> Response { - let client = reqwest::Client::new(); - let url = format!("{base_url}{path}"); - let req = client - .post(&url) - .header("X-Api-Key", api_key) - .json(&body); - - match req.send().await { - Ok(resp) => { - let status = StatusCode::from_u16(resp.status().as_u16()) - .unwrap_or(StatusCode::BAD_GATEWAY); - let body = resp.text().await.unwrap_or_default(); - (status, [("Content-Type", "application/json")], body).into_response() - } - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("failed to reach Indexarr: {e}")})), - ) - .into_response(), - } -} - -/// Extract Indexarr URL + API key from state, or return error response. -fn indexarr_config(state: &AppState) -> Result<(String, String), Response> { - let config = state.config.load(); - if !config.indexarr.enabled { - return Err(not_enabled()); - } - let api_key = config - .indexarr - .api_key - .clone() - .unwrap_or_default(); - Ok(( - config.indexarr.url.trim_end_matches('/').to_string(), - api_key, - )) -} - -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -/// GET /api/v1/indexarr/status — check if Indexarr is enabled and reachable. -async fn indexarr_status(State(state): State>) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(_) => { - return Json(json!({"enabled": false})).into_response(); - } - }; - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_default(); - - let req = client - .get(format!("{base_url}/api/v1/status")) - .header("X-Api-Key", &api_key); - - match req.send().await { - Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp.json().await.unwrap_or(json!({})); - Json(json!({ - "enabled": true, - "reachable": true, - "indexarr": body, - })) - .into_response() - } - Ok(resp) => Json(json!({ - "enabled": true, - "reachable": false, - "error": format!("Indexarr returned status {}", resp.status()), - })) - .into_response(), - Err(e) => Json(json!({ - "enabled": true, - "reachable": false, - "error": format!("{e}"), - })) - .into_response(), - } -} - -/// GET /api/v1/indexarr/search — proxy to Indexarr search API. -async fn indexarr_search( - State(state): State>, - Query(params): Query>, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_get(&base_url, &api_key, "/api/v1/search", ¶ms).await -} - -/// GET /api/v1/indexarr/recent — proxy to Indexarr recent torrents. -async fn indexarr_recent( - State(state): State>, - Query(params): Query>, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_get(&base_url, &api_key, "/api/v1/recent", ¶ms).await -} - -/// GET /api/v1/indexarr/trending — proxy to Indexarr trending torrents. -async fn indexarr_trending( - State(state): State>, - Query(params): Query>, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_get(&base_url, &api_key, "/api/v1/trending", ¶ms).await -} - -/// GET /api/v1/indexarr/torrent/{info_hash} — proxy to torrent detail. -async fn indexarr_torrent_detail( - State(state): State>, - Path(info_hash): Path, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_get( - &base_url, - &api_key, - &format!("/api/v1/torrent/{info_hash}"), - &HashMap::new(), - ) - .await -} - -/// GET /api/v1/indexarr/identity/status — proxy to identity status. -async fn indexarr_identity_status(State(state): State>) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_get(&base_url, &api_key, "/api/v1/identity/status", &HashMap::new()).await -} - -/// POST /api/v1/indexarr/identity/acknowledge — proxy to identity acknowledge. -async fn indexarr_identity_acknowledge(State(state): State>) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_post_json(&base_url, &api_key, "/api/v1/identity/acknowledge", json!({})).await -} - -/// GET /api/v1/indexarr/sync/preferences — proxy to sync preferences. -async fn indexarr_sync_prefs_get(State(state): State>) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_get( - &base_url, - &api_key, - "/api/v1/system/sync/preferences", - &HashMap::new(), - ) - .await -} - -/// POST /api/v1/indexarr/sync/preferences — proxy to update sync preferences. -async fn indexarr_sync_prefs_set( - State(state): State>, - Json(body): Json, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return r, - }; - proxy_post_json(&base_url, &api_key, "/api/v1/system/sync/preferences", body).await -} - -// --------------------------------------------------------------------------- -// Router -// --------------------------------------------------------------------------- - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/indexarr/status", get(indexarr_status)) - .route("/api/v1/indexarr/search", get(indexarr_search)) - .route("/api/v1/indexarr/recent", get(indexarr_recent)) - .route("/api/v1/indexarr/trending", get(indexarr_trending)) - .route( - "/api/v1/indexarr/torrent/{info_hash}", - get(indexarr_torrent_detail), - ) - .route( - "/api/v1/indexarr/identity/status", - get(indexarr_identity_status), - ) - .route( - "/api/v1/indexarr/identity/acknowledge", - post(indexarr_identity_acknowledge), - ) - .route( - "/api/v1/indexarr/sync/preferences", - get(indexarr_sync_prefs_get).post(indexarr_sync_prefs_set), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/indexers.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/indexers.rs deleted file mode 100644 index e3dd377a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/indexers.rs +++ /dev/null @@ -1,532 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct IndexerResponse { - id: i32, - name: String, - indexer_type: String, - base_url: String, - api_key: Option, - protocol: String, - categories: Option>, - enabled: bool, - priority: i32, - supports_search: bool, - supports_rss: bool, - config: Option, - last_rss_sync: Option>, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateIndexerRequest { - name: String, - indexer_type: String, - base_url: String, - api_key: Option, - protocol: String, - categories: Option>, - enabled: Option, - priority: Option, - supports_search: Option, - supports_rss: Option, - config: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateIndexerRequest { - name: Option, - indexer_type: Option, - base_url: Option, - api_key: Option, - protocol: Option, - categories: Option>, - enabled: Option, - priority: Option, - supports_search: Option, - supports_rss: Option, - config: Option, -} - -// --------------------------------------------------------------------------- -// CRUD endpoints (existing) -// --------------------------------------------------------------------------- - -async fn list_indexers(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, IndexerResponse>( - "SELECT id, name, indexer_type, base_url, api_key, protocol, categories, - enabled, priority, supports_search, supports_rss, config, last_rss_sync - FROM indexers ORDER BY priority, id", - ) - .fetch_all(pool) - .await - { - Ok(indexers) => Json(indexers).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list indexers"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn create_indexer( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if body.name.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "name cannot be empty"})), - ) - .into_response(); - } - - if body.base_url.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "base_url cannot be empty"})), - ) - .into_response(); - } - - let enabled = body.enabled.unwrap_or(true); - let priority = body.priority.unwrap_or(25); - let supports_search = body.supports_search.unwrap_or(true); - let supports_rss = body.supports_rss.unwrap_or(true); - - match sqlx::query_as::<_, IndexerResponse>( - "INSERT INTO indexers (name, indexer_type, base_url, api_key, protocol, categories, - enabled, priority, supports_search, supports_rss, config) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - RETURNING id, name, indexer_type, base_url, api_key, protocol, categories, - enabled, priority, supports_search, supports_rss, config, last_rss_sync", - ) - .bind(body.name.trim()) - .bind(&body.indexer_type) - .bind(body.base_url.trim()) - .bind(&body.api_key) - .bind(&body.protocol) - .bind(&body.categories) - .bind(enabled) - .bind(priority) - .bind(supports_search) - .bind(supports_rss) - .bind(&body.config) - .fetch_one(pool) - .await - { - Ok(indexer) => { - // Register the new indexer in the manager for immediate search - register_indexer_in_manager(&state, &indexer).await; - (StatusCode::CREATED, Json(json!(indexer))).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to create indexer"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn update_indexer( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, IndexerResponse>( - "UPDATE indexers SET - name = COALESCE($1, name), - indexer_type = COALESCE($2, indexer_type), - base_url = COALESCE($3, base_url), - api_key = COALESCE($4, api_key), - protocol = COALESCE($5, protocol), - categories = COALESCE($6, categories), - enabled = COALESCE($7, enabled), - priority = COALESCE($8, priority), - supports_search = COALESCE($9, supports_search), - supports_rss = COALESCE($10, supports_rss), - config = COALESCE($11, config) - WHERE id = $12 - RETURNING id, name, indexer_type, base_url, api_key, protocol, categories, - enabled, priority, supports_search, supports_rss, config, last_rss_sync", - ) - .bind(body.name.as_deref().map(str::trim)) - .bind(&body.indexer_type) - .bind(body.base_url.as_deref().map(str::trim)) - .bind(&body.api_key) - .bind(&body.protocol) - .bind(&body.categories) - .bind(body.enabled) - .bind(body.priority) - .bind(body.supports_search) - .bind(body.supports_rss) - .bind(&body.config) - .bind(id as i32) - .fetch_optional(pool) - .await - { - Ok(Some(indexer)) => { - // Update the indexer in the manager - let mut mgr = state.indexer_manager.write().await; - mgr.remove_indexer(id); - drop(mgr); - register_indexer_in_manager(&state, &indexer).await; - Json(json!(indexer)).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "indexer not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to update indexer"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_indexer( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query("DELETE FROM indexers WHERE id = $1") - .bind(id as i32) - .execute(pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "indexer not found"})), - ) - .into_response() - } else { - // Remove from live manager - let mut mgr = state.indexer_manager.write().await; - mgr.remove_indexer(id); - StatusCode::NO_CONTENT.into_response() - } - } - Err(e) => { - tracing::error!(error = %e, "failed to delete indexer"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn test_indexer( - State(_state): State>, - Path(_id): Path, -) -> impl IntoResponse { - // Stub: just return ok for now - Json(json!({ - "success": true, - "message": "connection test passed" - })) - .into_response() -} - -// --------------------------------------------------------------------------- -// Available indexer catalog (Cardigann definitions) -// --------------------------------------------------------------------------- - -/// Summary of an available indexer definition. -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct AvailableIndexer { - id: String, - name: String, - description: Option, - privacy: String, - language: Option, - protocol: String, - urls: Vec, - /// Setting fields the user needs to fill in (empty for public indexers). - settings: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct AvailableSetting { - name: String, - field_type: String, - label: Option, - default: Option, - options: Option>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SettingOption { - value: String, - label: String, -} - -/// List all available Cardigann indexer definitions. -/// Supports optional `?privacy=public` query filter. -async fn list_available_indexers( - State(state): State>, - axum::extract::Query(params): axum::extract::Query>, -) -> impl IntoResponse { - let engine = &state.cardigann_engine; - let privacy_filter = params.get("privacy").map(String::as_str); - - let mut available: Vec = engine - .definitions() - .values() - .filter(|def| { - if let Some(filter) = privacy_filter { - def.privacy.as_deref() == Some(filter) - } else { - true - } - }) - .map(|def| { - let settings: Vec = def - .settings - .as_ref() - .map(|fields| { - fields - .iter() - .filter(|f| f.field_type != "info" && f.field_type != "info_flaresolverr") - .map(|f| { - let default_str = f.default.as_ref().map(|v| match v { - serde_yaml::Value::String(s) => s.clone(), - serde_yaml::Value::Bool(b) => b.to_string(), - serde_yaml::Value::Number(n) => n.to_string(), - _ => String::new(), - }); - AvailableSetting { - name: f.name.clone(), - field_type: f.field_type.clone(), - label: f.label.clone(), - default: default_str, - options: f.options.as_ref().map(|opts| { - opts.iter() - .map(|(k, v)| SettingOption { - value: k.clone(), - label: v.clone(), - }) - .collect() - }), - } - }) - .collect() - }) - .unwrap_or_default(); - - AvailableIndexer { - id: def.id.clone(), - name: def.name.clone(), - description: def.description.clone(), - privacy: def.privacy.clone().unwrap_or_else(|| "unknown".into()), - language: def.language.clone(), - protocol: "torrent".into(), - urls: def.links.clone(), - settings, - } - }) - .collect(); - - available.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - Json(available).into_response() -} - -/// Get a single available indexer definition by ID. -async fn get_available_indexer( - State(state): State>, - Path(definition_id): Path, -) -> impl IntoResponse { - let engine = &state.cardigann_engine; - - match engine.get_definition(&definition_id) { - Some(def) => { - let settings: Vec = def - .settings - .as_ref() - .map(|fields| { - fields - .iter() - .filter(|f| f.field_type != "info" && f.field_type != "info_flaresolverr") - .map(|f| { - let default_str = f.default.as_ref().map(|v| match v { - serde_yaml::Value::String(s) => s.clone(), - serde_yaml::Value::Bool(b) => b.to_string(), - serde_yaml::Value::Number(n) => n.to_string(), - _ => String::new(), - }); - AvailableSetting { - name: f.name.clone(), - field_type: f.field_type.clone(), - label: f.label.clone(), - default: default_str, - options: f.options.as_ref().map(|opts| { - opts.iter() - .map(|(k, v)| SettingOption { - value: k.clone(), - label: v.clone(), - }) - .collect() - }), - } - }) - .collect() - }) - .unwrap_or_default(); - - let resp = AvailableIndexer { - id: def.id.clone(), - name: def.name.clone(), - description: def.description.clone(), - privacy: def.privacy.clone().unwrap_or_else(|| "unknown".into()), - language: def.language.clone(), - protocol: "torrent".into(), - urls: def.links.clone(), - settings, - }; - Json(json!(resp)).into_response() - } - None => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "definition not found"})), - ) - .into_response(), - } -} - -// --------------------------------------------------------------------------- -// Helper: register an indexer row into the live IndexerManager -// --------------------------------------------------------------------------- - -async fn register_indexer_in_manager(state: &AppState, row: &IndexerResponse) { - let mut mgr = state.indexer_manager.write().await; - - if row.indexer_type == "Cardigann" { - // Extract definition file from config - let definition_file = row - .config - .as_ref() - .and_then(|c| c.get("definitionFile")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if let Some(def) = state.cardigann_engine.get_definition(definition_file) { - // Build config map from the stored settings - let mut config = HashMap::new(); - config.insert("baseUrl".into(), row.base_url.clone()); - if let Some(ref key) = row.api_key { - config.insert("apiKey".into(), key.clone()); - } - // Merge any additional settings from config JSONB - if let Some(serde_json::Value::Object(map)) = row.config.as_ref() { - for (k, v) in map { - if k != "definitionFile" { - let val = match v { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - _ => continue, - }; - config.insert(k.clone(), val); - } - } - } - - match stackarr_cardigann::search::CardigannIndexer::new( - def.clone(), - config, - row.id as i64, - ) { - Ok(indexer) => { - mgr.add_cardigann_indexer(row.id as i64, &row.name, indexer, row.priority); - tracing::debug!(name = %row.name, "registered Cardigann indexer"); - } - Err(e) => { - tracing::warn!(name = %row.name, error = %e, "failed to create Cardigann indexer"); - } - } - } else { - tracing::warn!( - name = %row.name, - definition = definition_file, - "Cardigann definition not found" - ); - } - } else { - // Newznab/Torznab - let protocol = match row.protocol.as_str() { - "usenet" => stackarr_indexer::newznab::Protocol::Usenet, - _ => stackarr_indexer::newznab::Protocol::Torrent, - }; - mgr.add_indexer( - row.id as i64, - &row.name, - &row.base_url, - row.api_key.as_deref().unwrap_or(""), - protocol, - row.priority, - ); - } -} - -// --------------------------------------------------------------------------- -// Router -// --------------------------------------------------------------------------- - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/indexer", - get(list_indexers).post(create_indexer), - ) - .route( - "/api/v1/indexer/available", - get(list_available_indexers), - ) - .route( - "/api/v1/indexer/available/{id}", - get(get_available_indexer), - ) - .route( - "/api/v1/indexer/{id}", - axum::routing::put(update_indexer).delete(delete_indexer), - ) - .route("/api/v1/indexer/{id}/test", post(test_indexer)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/logs.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/logs.rs deleted file mode 100644 index 6c4ca55c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/logs.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::middleware::RequireApiKey; -use crate::AppState; - -#[derive(Deserialize)] -struct LogQuery { - level: Option, - limit: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LogResponse { - entries: Vec, - message: String, -} - -/// GET /api/v1/log — return recent log entries. -/// -/// Full WebSocket streaming is a future enhancement; for now this returns -/// a placeholder directing users to check container logs. -async fn get_logs( - _auth: RequireApiKey, - Query(params): Query, -) -> impl IntoResponse { - let _level = params.level.unwrap_or_else(|| "info".to_string()); - let _limit = params.limit.unwrap_or(100); - - // Log files are managed by tracing-subscriber / container runtime. - // This endpoint returns a message directing to container logs. - Json(LogResponse { - entries: Vec::new(), - message: "Use 'docker logs stackarr' or the container runtime to view full logs. WebSocket streaming will be added in a future release.".to_string(), - }) -} - -/// GET /api/v1/log/file — list available log files. -async fn list_log_files( - _auth: RequireApiKey, - State(state): State>, -) -> impl IntoResponse { - let config = state.config.load(); - let log_dir = config.general.data_dir.join("logs"); - - let mut files = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&log_dir) { - for entry in entries.flatten() { - if let Ok(meta) = entry.metadata() { - if meta.is_file() { - files.push(json!({ - "name": entry.file_name().to_string_lossy(), - "size": meta.len(), - })); - } - } - } - } - - Json(json!({ - "logDir": log_dir.to_string_lossy(), - "files": files, - })) -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/log", get(get_logs)) - .route("/api/v1/log/file", get(list_log_files)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/medialibraryfolders.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/medialibraryfolders.rs deleted file mode 100644 index c47cc15e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/medialibraryfolders.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct MediaLibraryFolderRow { - id: i32, - path: String, - media_type: String, - free_space: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct MediaLibraryFolderResponse { - id: i32, - path: String, - media_type: String, - free_space: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateMediaLibraryFolderRequest { - path: String, - media_type: String, -} - -/// Get free space for a path by parsing `df` output, or return None on error. -fn get_free_space(path: &str) -> Option { - let metadata = std::fs::metadata(path).ok()?; - if !metadata.is_dir() { - return None; - } - - // Use `df --output=avail -B1 ` which outputs available bytes - let output = std::process::Command::new("df") - .args(["--output=avail", "-B1", path]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8_lossy(&output.stdout); - // Output is two lines: header + value - let avail_str = stdout.lines().nth(1)?.trim(); - avail_str.parse::().ok() -} - -async fn list_media_library_folders(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, MediaLibraryFolderRow>( - "SELECT id, path, media_type, free_space FROM media_library_folders ORDER BY id", - ) - .fetch_all(pool) - .await - { - Ok(rows) => { - let folders: Vec = rows - .into_iter() - .map(|row| { - let free_space = get_free_space(&row.path).or(row.free_space); - MediaLibraryFolderResponse { - id: row.id, - path: row.path, - media_type: row.media_type, - free_space, - } - }) - .collect(); - Json(folders).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to list media library folders"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn create_media_library_folder( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - // Canonicalize and validate path to prevent traversal attacks - let canonical = match std::fs::canonicalize(&body.path) { - Ok(p) if p.is_dir() => p, - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "path does not exist or is not a directory"})), - ) - .into_response(); - } - }; - let canonical_str = canonical.to_string_lossy().to_string(); - - // Validate media_type - if body.media_type != "series" && body.media_type != "movie" { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "media_type must be 'series' or 'movie'"})), - ) - .into_response(); - } - - let pool = state.db.pool(); - let free_space = get_free_space(&canonical_str); - - match sqlx::query_as::<_, MediaLibraryFolderRow>( - "INSERT INTO media_library_folders (path, media_type, free_space, last_checked) - VALUES ($1, $2, $3, NOW()) - RETURNING id, path, media_type, free_space", - ) - .bind(&canonical_str) - .bind(&body.media_type) - .bind(free_space) - .fetch_one(pool) - .await - { - Ok(row) => ( - StatusCode::CREATED, - Json(json!(MediaLibraryFolderResponse { - id: row.id, - path: row.path, - media_type: row.media_type, - free_space: row.free_space, - })), - ) - .into_response(), - Err(e) => { - let msg = e.to_string(); - if msg.contains("duplicate key") || msg.contains("unique") { - ( - StatusCode::CONFLICT, - Json(json!({"error": "media library folder path already exists"})), - ) - .into_response() - } else { - tracing::error!(error = %e, "failed to create media library folder"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to create media library folder"})), - ) - .into_response() - } - } - } -} - -async fn delete_media_library_folder( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query("DELETE FROM media_library_folders WHERE id = $1") - .bind(id as i32) - .execute(pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media library folder not found"})), - ) - .into_response() - } else { - StatusCode::NO_CONTENT.into_response() - } - } - Err(e) => { - tracing::error!(error = %e, "failed to delete media library folder"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/medialibraryfolder", - get(list_media_library_folders).post(create_media_library_folder), - ) - .route("/api/v1/medialibraryfolder/{id}", axum::routing::delete(delete_media_library_folder)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/mod.rs deleted file mode 100644 index 8319df10..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/mod.rs +++ /dev/null @@ -1,55 +0,0 @@ -pub mod admin; -pub mod auth; -pub mod backup; -pub mod bootstrap; -pub mod user; - -/// Wrap an external image URL through the local image proxy. -pub(crate) fn proxy_image_url(url: &str) -> String { - format!("/api/v1/images/{url}") -} - -/// Extract an image URL from a JSONB images array by cover type (e.g. "poster", "fanart"). -/// Returns a proxied URL through `/api/v1/images/` for local caching. -pub(crate) fn extract_image_url(images: &Option, cover_type: &str) -> Option { - images.as_ref()?.as_array()?.iter().find_map(|img| { - if img.get("coverType")?.as_str()? == cover_type { - img.get("remoteUrl")?.as_str().map(|url| proxy_image_url(url)) - } else { - None - } - }) -} -pub mod blocklist; -pub mod calendar; -pub mod discover; -pub mod downloadclients; -pub mod episodes; -pub mod general; -pub mod health; -pub mod images; -pub mod history; -pub mod importlists; -pub mod indexarr; -pub mod indexers; -pub mod logs; -pub mod movies; -pub mod naming; -pub mod notifications; -pub mod plex; -pub mod progress; -pub mod quality; -pub mod queue; -pub mod remote; -pub mod releases; -pub mod requests; -pub mod search; -pub mod medialibraryfolders; -pub mod series; -pub mod stream; -pub mod system; -pub mod tags; -pub mod torrent; -pub mod usenet; -pub mod wanted; -pub mod watchlist; diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/movies.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/movies.rs deleted file mode 100644 index 4e2da1de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/movies.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use stackarr_core::models::media::{MediaFile, Movie}; -use stackarr_media::{CreateMovieInput, MovieService, UpdateMovieInput}; -use stackarr_metadata::TmdbClient; - -use super::extract_image_url; -use crate::AppState; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct MovieResponse { - #[serde(flatten)] - movie: Movie, - poster_url: Option, - fanart_url: Option, - has_file: bool, - movie_file: Option, -} - -fn enrich_movie(movie: Movie, files: &HashMap) -> MovieResponse { - let poster_url = extract_image_url(&movie.images, "poster"); - let fanart_url = extract_image_url(&movie.images, "fanart"); - let has_file = movie.movie_file_id.is_some(); - let movie_file = movie - .movie_file_id - .and_then(|fid| files.get(&fid).cloned()); - MovieResponse { - movie, - poster_url, - fanart_url, - has_file, - movie_file, - } -} - -async fn fetch_media_files( - pool: &sqlx::PgPool, - file_ids: &[i64], -) -> Result, sqlx::Error> { - if file_ids.is_empty() { - return Ok(HashMap::new()); - } - let rows = sqlx::query_as::<_, MediaFile>( - "SELECT * FROM media_files WHERE id = ANY($1)", - ) - .bind(file_ids) - .fetch_all(pool) - .await?; - Ok(rows.into_iter().map(|f| (f.id, f)).collect()) -} - -async fn list_movies(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - let svc = MovieService::new(pool.clone()); - match svc.list().await { - Ok(list) => { - let file_ids: Vec = list.iter().filter_map(|m| m.movie_file_id).collect(); - let files = fetch_media_files(pool, &file_ids) - .await - .unwrap_or_default(); - let responses: Vec = list - .into_iter() - .map(|m| enrich_movie(m, &files)) - .collect(); - Json(responses).into_response() - } - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -async fn get_movie( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - let svc = MovieService::new(pool.clone()); - match svc.get(id).await { - Ok(m) => { - let file_ids: Vec = m.movie_file_id.into_iter().collect(); - let files = fetch_media_files(pool, &file_ids) - .await - .unwrap_or_default(); - Json(enrich_movie(m, &files)).into_response() - } - Err(_) => StatusCode::NOT_FOUND.into_response(), - } -} - -async fn create_movie( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let svc = MovieService::new(state.db.pool().clone()); - match svc.create(input).await { - Ok(m) => { - let files = HashMap::new(); - (StatusCode::CREATED, Json(enrich_movie(m, &files))).into_response() - } - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn update_movie( - State(state): State>, - Path(id): Path, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - let svc = MovieService::new(pool.clone()); - match svc.update(id, input).await { - Ok(m) => { - let file_ids: Vec = m.movie_file_id.into_iter().collect(); - let files = fetch_media_files(pool, &file_ids) - .await - .unwrap_or_default(); - Json(enrich_movie(m, &files)).into_response() - } - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn delete_movie( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let svc = MovieService::new(state.db.pool().clone()); - match svc.delete(id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -// ── TMDB Lookup ───────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct LookupQuery { - term: String, -} - -async fn lookup_movie( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - // Get TMDB API key from env or DB - let api_key = std::env::var("STACKARR_TMDB_API_KEY").ok(); - - let api_key = match api_key { - Some(key) if !key.is_empty() => key, - _ => { - let pool = state.db.pool(); - match sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'tmdb_api_key'", - ) - .fetch_optional(pool) - .await - { - Ok(Some(val)) => match val.as_str() { - Some(k) if !k.is_empty() => k.to_string(), - _ => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured. Set STACKARR_TMDB_API_KEY or configure via settings."})), - ) - .into_response(); - } - }, - _ => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured. Set STACKARR_TMDB_API_KEY or configure via settings."})), - ) - .into_response(); - } - } - } - }; - - let client = TmdbClient::new(api_key); - match client.search_movie(&query.term, None).await { - Ok(results) => Json(json!(results)).into_response(), - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("TMDB search failed: {e}")})), - ) - .into_response(), - } -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/movies", get(list_movies).post(create_movie)) - .route( - "/api/v1/movies/{id}", - get(get_movie).put(update_movie).delete(delete_movie), - ) - .route("/api/v1/movies/lookup", get(lookup_movie)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/naming.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/naming.rs deleted file mode 100644 index f7608171..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/naming.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::sync::Arc; - -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct NamingConfigRow { - id: i32, - media_type: String, - rename_files: bool, - standard_format: Option, - daily_format: Option, - anime_format: Option, - season_folder_format: Option, - movie_format: Option, - movie_folder_format: Option, - colon_replacement: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct NamingConfigResponse { - series: Option, - movie: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateNamingConfigRequest { - series: Option, - movie: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateNamingEntry { - rename_files: Option, - standard_format: Option, - daily_format: Option, - anime_format: Option, - season_folder_format: Option, - movie_format: Option, - movie_folder_format: Option, - colon_replacement: Option, -} - -async fn get_naming_config(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, NamingConfigRow>( - "SELECT id, media_type, rename_files, standard_format, daily_format, anime_format, - season_folder_format, movie_format, movie_folder_format, colon_replacement - FROM naming_config ORDER BY media_type", - ) - .fetch_all(pool) - .await - { - Ok(rows) => { - let mut series = None; - let mut movie = None; - for row in rows { - match row.media_type.as_str() { - "series" => series = Some(row), - "movie" => movie = Some(row), - _ => {} - } - } - Json(NamingConfigResponse { series, movie }).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to fetch naming config"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn update_naming_config( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if let Some(series) = &body.series { - if let Err(e) = sqlx::query( - "UPDATE naming_config SET - rename_files = COALESCE($1, rename_files), - standard_format = COALESCE($2, standard_format), - daily_format = COALESCE($3, daily_format), - anime_format = COALESCE($4, anime_format), - season_folder_format = COALESCE($5, season_folder_format), - colon_replacement = COALESCE($6, colon_replacement) - WHERE media_type = 'series'", - ) - .bind(series.rename_files) - .bind(&series.standard_format) - .bind(&series.daily_format) - .bind(&series.anime_format) - .bind(&series.season_folder_format) - .bind(&series.colon_replacement) - .execute(pool) - .await - { - tracing::error!(error = %e, "failed to update series naming config"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - - if let Some(movie) = &body.movie { - if let Err(e) = sqlx::query( - "UPDATE naming_config SET - rename_files = COALESCE($1, rename_files), - movie_format = COALESCE($2, movie_format), - movie_folder_format = COALESCE($3, movie_folder_format), - colon_replacement = COALESCE($4, colon_replacement) - WHERE media_type = 'movie'", - ) - .bind(movie.rename_files) - .bind(&movie.movie_format) - .bind(&movie.movie_folder_format) - .bind(&movie.colon_replacement) - .execute(pool) - .await - { - tracing::error!(error = %e, "failed to update movie naming config"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - - // Return the updated config - get_naming_config(State(state)).await.into_response() -} - -pub fn router() -> Router> { - Router::new().route( - "/api/v1/config/naming", - get(get_naming_config).put(update_naming_config), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/notifications.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/notifications.rs deleted file mode 100644 index 234ffe49..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/notifications.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, post, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::middleware::RequireUser; -use crate::AppState; - -// ── Types ──────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct NotificationQuery { - unread: Option, - limit: Option, - offset: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PushSubscriptionBody { - endpoint: String, - p256dh: String, - auth: String, - user_agent: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PushSubscriptionDeleteBody { - endpoint: String, -} - -// ── Notification routes ────────────────────────────────────────────────────── - -async fn list_notifications( - State(state): State>, - RequireUser(auth_user): RequireUser, - Query(q): Query, -) -> impl IntoResponse { - let unread_only = q.unread.unwrap_or(false); - let limit = q.limit.unwrap_or(50).min(200); - let offset = q.offset.unwrap_or(0); - - match state - .db - .list_notifications(auth_user.user_id, unread_only, limit, offset) - .await - { - Ok(notifications) => { - Json(serde_json::to_value(notifications).unwrap_or_default()).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to list notifications"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn unread_count( - State(state): State>, - RequireUser(auth_user): RequireUser, -) -> impl IntoResponse { - match state.db.unread_notification_count(auth_user.user_id).await { - Ok(count) => Json(json!({ "count": count })).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get unread count"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn mark_read( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(id): Path, -) -> impl IntoResponse { - match state - .db - .mark_notification_read(id, auth_user.user_id) - .await - { - Ok(true) => Json(json!({"ok": true})).into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "notification not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to mark notification read"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn mark_all_read( - State(state): State>, - RequireUser(auth_user): RequireUser, -) -> impl IntoResponse { - match state - .db - .mark_all_notifications_read(auth_user.user_id) - .await - { - Ok(count) => Json(json!({"marked": count})).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to mark all notifications read"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Push subscription routes ───────────────────────────────────────────────── - -async fn save_push_subscription( - State(state): State>, - RequireUser(auth_user): RequireUser, - Json(body): Json, -) -> impl IntoResponse { - if body.endpoint.is_empty() || body.p256dh.is_empty() || body.auth.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "endpoint, p256dh, and auth are required"})), - ) - .into_response(); - } - - match state - .db - .save_push_subscription( - auth_user.user_id, - &body.endpoint, - &body.p256dh, - &body.auth, - body.user_agent.as_deref(), - ) - .await - { - Ok(sub) => Json(serde_json::to_value(sub).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to save push subscription"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn remove_push_subscription( - State(state): State>, - RequireUser(auth_user): RequireUser, - Json(body): Json, -) -> impl IntoResponse { - match state - .db - .delete_push_subscription(&body.endpoint, auth_user.user_id) - .await - { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "subscription not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete push subscription"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/user/notifications", get(list_notifications)) - .route( - "/api/v1/user/notifications/unread-count", - get(unread_count), - ) - .route( - "/api/v1/user/notifications/{id}/read", - put(mark_read), - ) - .route( - "/api/v1/user/notifications/read-all", - put(mark_all_read), - ) - .route( - "/api/v1/user/push-subscription", - post(save_push_subscription).delete(remove_push_subscription), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/plex.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/plex.rs deleted file mode 100644 index 31b44678..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/plex.rs +++ /dev/null @@ -1,390 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post, put}; -use axum::{Json, Router}; -use serde_json::json; - -use stackarr_plex::types::*; -use stackarr_plex::{PlexApi, PlexScanner, PlexTvApi}; - -use crate::AppState; - -// ── Plex Server CRUD ─────────────────────────────────────────────────────── - -async fn list_servers(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - match sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at \ - FROM plex_servers ORDER BY id", - ) - .fetch_all(pool) - .await - { - Ok(servers) => Json(servers).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list plex servers"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn create_server( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - let name = input.name.unwrap_or_else(|| "Plex".to_string()); - let port = input.port.unwrap_or(32400); - let use_ssl = input.use_ssl.unwrap_or(false); - - // Validate connection by fetching server info - let api = PlexApi::new(&input.ip, port, use_ssl, &input.auth_token); - let machine_id = match api.get_status().await { - Ok(info) => info.machine_identifier, - Err(e) => { - tracing::error!(error = %e, "failed to connect to plex server during setup"); - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "failed to connect to Plex server"})), - ) - .into_response(); - } - }; - - match sqlx::query_as::<_, PlexServer>( - "INSERT INTO plex_servers (name, machine_id, ip, port, use_ssl, auth_token, web_app_url) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) \ - RETURNING id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at", - ) - .bind(&name) - .bind(&machine_id) - .bind(&input.ip) - .bind(port) - .bind(use_ssl) - .bind(&input.auth_token) - .bind(&input.web_app_url) - .fetch_one(pool) - .await - { - Ok(server) => (StatusCode::CREATED, Json(server)).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to insert plex server"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn update_server( - State(state): State>, - Path(server_id): Path, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - let existing = sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at \ - FROM plex_servers WHERE id = $1", - ) - .bind(server_id) - .fetch_optional(pool) - .await; - - let existing = match existing { - Ok(Some(s)) => s, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, server_id, "failed to fetch plex server for update"); - return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(); - } - }; - - let name = input.name.unwrap_or(existing.name); - let ip = input.ip.unwrap_or(existing.ip); - let port = input.port.unwrap_or(existing.port); - let use_ssl = input.use_ssl.unwrap_or(existing.use_ssl); - let auth_token = input.auth_token.or(existing.auth_token); - let web_app_url = input.web_app_url.or(existing.web_app_url); - - match sqlx::query_as::<_, PlexServer>( - "UPDATE plex_servers SET name = $1, ip = $2, port = $3, use_ssl = $4, auth_token = $5, \ - web_app_url = $6, updated_at = NOW() WHERE id = $7 \ - RETURNING id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at", - ) - .bind(&name) - .bind(&ip) - .bind(port) - .bind(use_ssl) - .bind(&auth_token) - .bind(&web_app_url) - .bind(server_id) - .fetch_one(pool) - .await - { - Ok(server) => Json(server).into_response(), - Err(e) => { - tracing::error!(error = %e, server_id, "failed to update plex server"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn delete_server( - State(state): State>, - Path(server_id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - match sqlx::query("DELETE FROM plex_servers WHERE id = $1") - .bind(server_id) - .execute(pool) - .await - { - Ok(r) if r.rows_affected() > 0 => StatusCode::NO_CONTENT.into_response(), - Ok(_) => StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, server_id, "failed to delete plex server"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -// ── Libraries ────────────────────────────────────────────────────────────── - -/// Sync libraries from the Plex server and return the list. -async fn sync_libraries( - State(state): State>, - Path(server_id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - let server = match sqlx::query_as::<_, PlexServer>( - "SELECT id, name, machine_id, ip, port, use_ssl, auth_token, web_app_url, created_at, updated_at \ - FROM plex_servers WHERE id = $1", - ) - .bind(server_id) - .fetch_optional(pool) - .await - { - Ok(Some(s)) => s, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, server_id, "failed to fetch plex server for library sync"); - return (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(); - } - }; - - let Some(api) = PlexApi::from_server(&server) else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "No auth token configured"})), - ) - .into_response(); - }; - - let sections = match api.get_libraries().await { - Ok(s) => s, - Err(e) => { - tracing::error!(error = %e, server_id, "failed to fetch plex libraries from server"); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "failed to fetch libraries from Plex server"})), - ) - .into_response(); - } - }; - - // Upsert each library section - for section in §ions { - let lib_type = match section.section_type.as_str() { - "movie" => "movie", - "show" => "show", - _ => continue, // skip music, photos, etc. - }; - - let _ = sqlx::query( - "INSERT INTO plex_libraries (plex_server_id, section_id, name, library_type) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (plex_server_id, section_id) DO UPDATE SET name = $3", - ) - .bind(server_id) - .bind(§ion.key) - .bind(§ion.title) - .bind(lib_type) - .execute(pool) - .await; - } - - // Return the updated list - match sqlx::query_as::<_, PlexLibrary>( - "SELECT id, plex_server_id, section_id, name, enabled, library_type, last_scan \ - FROM plex_libraries WHERE plex_server_id = $1 ORDER BY id", - ) - .bind(server_id) - .fetch_all(pool) - .await - { - Ok(libs) => Json(libs).into_response(), - Err(e) => { - tracing::error!(error = %e, server_id, "failed to list plex libraries"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn update_library( - State(state): State>, - Path(library_id): Path, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - match sqlx::query_as::<_, PlexLibrary>( - "UPDATE plex_libraries SET enabled = $1 WHERE id = $2 \ - RETURNING id, plex_server_id, section_id, name, enabled, library_type, last_scan", - ) - .bind(input.enabled) - .bind(library_id) - .fetch_optional(pool) - .await - { - Ok(Some(lib)) => Json(lib).into_response(), - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, library_id, "failed to update plex library"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -// ── Scan triggers ────────────────────────────────────────────────────────── - -async fn trigger_full_scan(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool().clone(); - // Spawn as background task so we don't block the response - tokio::spawn(async move { - let scanner = PlexScanner::new(pool); - match scanner.full_scan().await { - Ok(report) => tracing::info!(?report, "plex full scan complete"), - Err(e) => tracing::error!(error = %e, "plex full scan failed"), - } - }); - Json(json!({"status": "scan started"})) -} - -async fn trigger_recent_scan(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool().clone(); - tokio::spawn(async move { - let scanner = PlexScanner::new(pool); - match scanner.recent_scan().await { - Ok(report) => tracing::info!(?report, "plex recent scan complete"), - Err(e) => tracing::error!(error = %e, "plex recent scan failed"), - } - }); - Json(json!({"status": "recent scan started"})) -} - -// ── OAuth / Auth ─────────────────────────────────────────────────────────── - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct PlexAuthInput { - auth_token: String, -} - -/// Validate a Plex auth token and return user info. -async fn validate_plex_token(Json(input): Json) -> impl IntoResponse { - let tv_api = PlexTvApi::new(&input.auth_token); - match tv_api.get_user().await { - Ok(user) => Json(json!({ - "valid": true, - "user": user - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "plex token validation failed"); - ( - StatusCode::UNAUTHORIZED, - Json(json!({"valid": false, "error": "token validation failed"})), - ) - .into_response() - } - } -} - -/// Discover Plex servers for a given token. -async fn discover_servers(Json(input): Json) -> impl IntoResponse { - let tv_api = PlexTvApi::new(&input.auth_token); - match tv_api.get_servers().await { - Ok(servers) => Json(servers).into_response(), - Err(e) => { - tracing::error!(error = %e, "plex server discovery failed"); - ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "failed to discover plex servers"})), - ) - .into_response() - } - } -} - -// ── Watchlist ────────────────────────────────────────────────────────────── - -async fn list_watchlist(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - match sqlx::query_as::<_, WatchlistEntry>( - "SELECT id, tmdb_id, media_type, plex_rating_key, auto_requested, created_at \ - FROM watchlist ORDER BY created_at DESC", - ) - .fetch_all(pool) - .await - { - Ok(entries) => Json(entries).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list watchlist entries"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() - } - } -} - -async fn trigger_watchlist_sync(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool().clone(); - tokio::spawn(async move { - let sync = stackarr_plex::WatchlistSync::new(pool); - match sync.run().await { - Ok(report) => tracing::info!(?report, "watchlist sync complete"), - Err(e) => tracing::error!(error = %e, "watchlist sync failed"), - } - }); - Json(json!({"status": "watchlist sync started"})) -} - -// ── Router ───────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - // Server management - .route("/api/v1/plex/servers", get(list_servers).post(create_server)) - .route( - "/api/v1/plex/servers/{server_id}", - put(update_server).delete(delete_server), - ) - // Library management - .route( - "/api/v1/plex/servers/{server_id}/libraries", - get(sync_libraries), - ) - .route( - "/api/v1/plex/libraries/{library_id}", - put(update_library), - ) - // Scanning - .route("/api/v1/plex/scan/full", post(trigger_full_scan)) - .route("/api/v1/plex/scan/recent", post(trigger_recent_scan)) - // Auth - .route("/api/v1/plex/auth/validate", post(validate_plex_token)) - .route("/api/v1/plex/auth/servers", post(discover_servers)) - // Watchlist - .route("/api/v1/plex/watchlist", get(list_watchlist)) - .route("/api/v1/plex/watchlist/sync", post(trigger_watchlist_sync)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/progress.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/progress.rs deleted file mode 100644 index 8fba64f7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/progress.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::middleware::RequireUser; -use crate::AppState; - -// ── Query params ───────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ContinueWatchingQuery { - limit: Option, -} - -// ── Upsert body ────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpsertProgressRequest { - position_secs: f32, - duration_secs: f32, -} - -// ── GET /api/v1/user/progress/continue ─────────────────────────────────────── - -async fn get_continue_watching( - State(state): State>, - RequireUser(auth_user): RequireUser, - Query(params): Query, -) -> impl IntoResponse { - let limit = params.limit.unwrap_or(20).min(100); - - match state - .db - .get_continue_watching_enriched(auth_user.user_id, limit) - .await - { - Ok(items) => Json(serde_json::to_value(items).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get continue watching"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── GET /api/v1/user/progress/{mediaFileId} ────────────────────────────────── - -async fn get_progress( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(media_file_id): Path, -) -> impl IntoResponse { - match state - .db - .get_watch_progress(auth_user.user_id, media_file_id) - .await - { - Ok(Some(progress)) => { - Json(serde_json::to_value(progress).unwrap_or_default()).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "no progress found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get watch progress"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── PUT /api/v1/user/progress/{mediaFileId} ────────────────────────────────── - -async fn upsert_progress( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(media_file_id): Path, - Json(body): Json, -) -> impl IntoResponse { - // Resolve media_file -> media_type, media_id, episode_id - let resolved = match state.db.resolve_media_file(media_file_id).await { - Ok(Some(r)) => r, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media file not found"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to resolve media file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let (media_type, media_id, episode_id) = resolved; - - // Auto-complete at >90% progress - let completed = body.duration_secs > 0.0 - && (body.position_secs / body.duration_secs) > 0.9; - - match state - .db - .upsert_watch_progress( - auth_user.user_id, - media_file_id, - &media_type, - media_id, - episode_id, - body.position_secs, - body.duration_secs, - completed, - ) - .await - { - Ok(progress) => Json(serde_json::to_value(progress).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to upsert watch progress"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── DELETE /api/v1/user/progress/{mediaFileId} ─────────────────────────────── - -async fn delete_progress( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(media_file_id): Path, -) -> impl IntoResponse { - match state - .db - .delete_watch_progress(auth_user.user_id, media_file_id) - .await - { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "no progress found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete watch progress"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── GET /api/v1/user/progress/series/{seriesId} ───────────────────────────── - -async fn get_series_progress( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(series_id): Path, -) -> impl IntoResponse { - match state - .db - .get_series_progress(auth_user.user_id, series_id) - .await - { - Ok(rows) => Json(serde_json::to_value(rows).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get series progress"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── GET /api/v1/user/progress/movie/{movieId} ─────────────────────────────── - -async fn get_movie_progress( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(movie_id): Path, -) -> impl IntoResponse { - match state - .db - .get_movie_progress(auth_user.user_id, movie_id) - .await - { - Ok(Some(progress)) => { - Json(serde_json::to_value(progress).unwrap_or_default()).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "no progress found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get movie progress"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/user/progress/continue", - get(get_continue_watching), - ) - .route( - "/api/v1/user/progress/series/{seriesId}", - get(get_series_progress), - ) - .route( - "/api/v1/user/progress/movie/{movieId}", - get(get_movie_progress), - ) - .route( - "/api/v1/user/progress/{mediaFileId}", - get(get_progress).put(upsert_progress).delete(delete_progress), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/quality.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/quality.rs deleted file mode 100644 index 480fdd28..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/quality.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; - -use stackarr_quality::{CreateProfileInput, QualityProfileService, UpdateProfileInput}; - -use crate::AppState; - -async fn list_profiles(State(state): State>) -> impl IntoResponse { - let svc = QualityProfileService::new(state.db.pool().clone()); - match svc.list().await { - Ok(list) => Json(list).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -async fn get_profile( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let svc = QualityProfileService::new(state.db.pool().clone()); - match svc.get(id).await { - Ok(p) => Json(p).into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), - } -} - -async fn create_profile( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let svc = QualityProfileService::new(state.db.pool().clone()); - match svc.create(input).await { - Ok(p) => (StatusCode::CREATED, Json(p)).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn update_profile( - State(state): State>, - Path(id): Path, - Json(input): Json, -) -> impl IntoResponse { - let svc = QualityProfileService::new(state.db.pool().clone()); - match svc.update(id, input).await { - Ok(p) => Json(p).into_response(), - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn delete_profile( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let svc = QualityProfileService::new(state.db.pool().clone()); - match svc.delete(id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -pub fn router() -> Router> { - Router::new() - .route( - "/api/v1/qualityprofile", - get(list_profiles).post(create_profile), - ) - .route( - "/api/v1/qualityprofile/{id}", - get(get_profile).put(update_profile).delete(delete_profile), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/queue.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/queue.rs deleted file mode 100644 index 89dcd3d2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/queue.rs +++ /dev/null @@ -1,41 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; - -use stackarr_core::models::QueueItem; - -use crate::AppState; - -async fn list_queue(State(state): State>) -> impl IntoResponse { - let result = sqlx::query_as::<_, QueueItem>("SELECT * FROM queue ORDER BY added_at DESC") - .fetch_all(state.db.pool()) - .await; - match result { - Ok(items) => Json(items).into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -async fn delete_queue_item( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let result = sqlx::query("DELETE FROM queue WHERE id = $1") - .bind(id) - .execute(state.db.pool()) - .await; - match result { - Ok(_) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/queue", get(list_queue)) - .route("/api/v1/queue/{id}", axum::routing::delete(delete_queue_item)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/releases.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/releases.rs deleted file mode 100644 index 93680c34..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/releases.rs +++ /dev/null @@ -1,659 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::Deserialize; - -use stackarr_core::models::{DownloadProtocol, QualityProfile, ReleaseInfo}; -use stackarr_indexer::search::{MovieSearchCriteria, TvSearchCriteria}; -use stackarr_quality::custom_formats::{CustomFormatDef, CustomFormatEngine}; -use stackarr_quality::{DecisionContext, DecisionEngine, DownloadDecision, GrabStrategy, rank_releases}; - -use crate::AppState; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchQuery { - #[serde(default)] - term: String, - /// Optional media type hint: "series" or "movie" (defaults to series) - #[serde(default)] - media_type: Option, - /// Optional quality profile ID to use for decision engine - #[serde(default)] - quality_profile_id: Option, - /// Optional series ID — enables existing file quality context for episodes - #[serde(default)] - series_id: Option, - /// Optional movie ID — enables existing file quality context - #[serde(default)] - movie_id: Option, - /// Optional episode ID — narrows existing file lookup to a specific episode - #[serde(default)] - episode_id: Option, -} - -/// Convert an indexer ReleaseInfo into a core model ReleaseInfo. -fn indexer_to_core(r: stackarr_indexer::ReleaseInfo) -> ReleaseInfo { - ReleaseInfo { - guid: r.guid, - title: r.title, - download_url: r.download_url, - info_url: r.info_url, - indexer_id: r.indexer_id, - indexer_name: r.indexer_name, - protocol: match r.protocol { - stackarr_indexer::newznab::Protocol::Torrent => DownloadProtocol::Torrent, - stackarr_indexer::newznab::Protocol::Usenet => DownloadProtocol::Usenet, - }, - size: r.size, - age_days: r.age_days, - publish_date: r.publish_date, - info_hash: r.info_hash, - magnet_url: r.magnet_url, - seeders: r.seeders, - leechers: r.leechers, - nzb_url: r.nzb_url, - tvdb_id: r.tvdb_id, - imdb_id: r.imdb_id, - tmdb_id: r.tmdb_id, - categories: r.categories, - indexer_flags: r.indexer_flags, - indexer_priority: r.indexer_priority, - } -} - -async fn search_releases( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - tracing::info!(term = %query.term, "release search requested"); - - if query.term.is_empty() { - return Json(serde_json::json!([])).into_response(); - } - - // Load quality profile (use requested or first available) - let pool = state.db.pool(); - let profile: QualityProfile = match query.quality_profile_id { - Some(id) => { - match sqlx::query_as::<_, QualityProfile>( - "SELECT * FROM quality_profiles WHERE id = $1", - ) - .bind(id) - .fetch_optional(pool) - .await - { - Ok(Some(p)) => p, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "quality profile not found"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to load quality profile"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "failed to load quality profile"})), - ) - .into_response(); - } - } - } - None => { - match sqlx::query_as::<_, QualityProfile>( - "SELECT * FROM quality_profiles ORDER BY id LIMIT 1", - ) - .fetch_optional(pool) - .await - { - Ok(Some(p)) => p, - Ok(None) => { - // No profiles configured — return raw results without decision engine - let mgr = state.indexer_manager.read().await; - let criteria = TvSearchCriteria { - query: Some(query.term.clone()), - tvdb_id: None, - season: None, - episode: None, - categories: vec![], - }; - let releases = mgr.search_series(&criteria).await.unwrap_or_default(); - let core_releases: Vec = - releases.into_iter().map(indexer_to_core).collect(); - return Json(core_releases).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to load quality profiles"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error": "failed to load quality profiles"})), - ) - .into_response(); - } - } - } - }; - - // Search indexers - let mgr = state.indexer_manager.read().await; - let is_movie = query - .media_type - .as_deref() - .is_some_and(|t| t.eq_ignore_ascii_case("movie")); - - let indexer_results = if is_movie { - let criteria = MovieSearchCriteria { - query: Some(query.term.clone()), - tmdb_id: None, - imdb_id: None, - categories: vec![], - }; - mgr.search_movies(&criteria).await - } else { - let criteria = TvSearchCriteria { - query: Some(query.term.clone()), - tvdb_id: None, - season: None, - episode: None, - categories: vec![], - }; - mgr.search_series(&criteria).await - }; - drop(mgr); - - let releases = match indexer_results { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "indexer search failed"); - return ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": "indexer search failed"})), - ) - .into_response(); - } - }; - - // Check which guids are already in queue or history - let guids: Vec = releases.iter().map(|r| r.guid.clone()).collect(); - - let queued_guids: std::collections::HashSet = sqlx::query_scalar( - "SELECT download_id FROM queue WHERE download_id = ANY($1)", - ) - .bind(&guids) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .collect(); - - let history_guids: std::collections::HashSet = sqlx::query_scalar( - "SELECT download_id FROM history WHERE download_id = ANY($1) AND event_type = 'grabbed'", - ) - .bind(&guids) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .collect(); - - // Check which release titles are blocklisted - let release_titles: Vec = releases.iter().map(|r| r.title.clone()).collect(); - let blocklisted_titles: std::collections::HashSet = sqlx::query_scalar( - "SELECT source_title FROM blocklist WHERE source_title = ANY($1)", - ) - .bind(&release_titles) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .collect(); - - // Load custom formats and profile scores for CF scoring - let cf_formats: Vec = sqlx::query_as::<_, stackarr_core::models::CustomFormat>( - "SELECT * FROM custom_formats", - ) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .filter_map(|cf| { - let specs = serde_json::from_value(cf.specifications).ok()?; - Some(CustomFormatDef { - id: cf.id as i64, - name: cf.name, - specifications: specs, - }) - }) - .collect(); - - let cf_scores: Vec<(i64, i32)> = sqlx::query_as::<_, (i32, i32)>( - "SELECT format_id, score FROM custom_format_scores WHERE profile_id = $1", - ) - .bind(profile.id) - .fetch_all(pool) - .await - .unwrap_or_default() - .into_iter() - .map(|(fid, score)| (fid as i64, score)) - .collect(); - - let cf_engine = CustomFormatEngine::new(); - - // Look up existing file quality from the database when media context is provided. - // This allows QualityCutoffSpec and CustomFormatCutoffSpec to reject releases - // when the existing file already meets the cutoff (matching Sonarr/Radarr behavior). - let (existing_quality, existing_cf_score) = lookup_existing_file_quality( - pool, - &cf_engine, - &cf_formats, - &cf_scores, - is_movie, - query.series_id, - query.movie_id, - query.episode_id, - ) - .await; - - // Look up highest quality item in queue for this media item (not just by guid). - // This allows QueueConflictSpec to reject releases when the same episode/movie - // already has a queued download at equal or higher quality. - let queued_quality = lookup_queued_quality( - pool, - is_movie, - query.series_id, - query.movie_id, - query.episode_id, - ) - .await; - - // Look up movie's original language for LanguageSpec (Radarr -2/Original profiles) - let original_language = if is_movie { - if let Some(mid) = query.movie_id { - sqlx::query_scalar::<_, Option>( - "SELECT original_language FROM movies WHERE id = $1", - ) - .bind(mid) - .fetch_optional(pool) - .await - .ok() - .flatten() - .flatten() - } else { - None - } - } else { - None - }; - - // Run decision engine on each release - let engine = DecisionEngine::new(); - let decisions: Vec = releases - .into_iter() - .map(|r| { - let guid = r.guid.clone(); - let core_release = indexer_to_core(r); - let title = core_release.title.clone(); - - // Score this release against custom formats - let cf_result = cf_engine.score_release( - &title, - &cf_formats, - &cf_scores, - ); - - let ctx = DecisionContext { - release: core_release, - profile: profile.clone(), - existing_quality, - existing_custom_format_score: existing_cf_score, - release_custom_format_score: cf_result.total_score, - in_queue: queued_guids.contains(&guid), - in_blocklist: blocklisted_titles.contains(&title), - already_grabbed: history_guids.contains(&guid), - queued_quality, - original_language, - }; - engine.decide(ctx) - }) - .collect(); - - // Load grab strategy from app_config - let strategy: GrabStrategy = sqlx::query_scalar::<_, String>( - "SELECT value #>> '{}' FROM app_config WHERE key = 'grab_strategy'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|s| serde_json::from_str(&format!("\"{s}\"")).ok()) - .unwrap_or_default(); - - let ranked = rank_releases(decisions, strategy); - Json(ranked).into_response() -} - -/// Look up the highest quality item in the download queue for a specific media item. -/// Returns Some(quality_num) if there's a queued item, None otherwise. -async fn lookup_queued_quality( - pool: &sqlx::PgPool, - is_movie: bool, - series_id: Option, - movie_id: Option, - episode_id: Option, -) -> Option { - let media_type = if is_movie { "movie" } else { "series" }; - - let media_id = if is_movie { - movie_id? - } else { - series_id? - }; - - // Query the queue for the highest quality item matching this media item. - // For episodes, also filter by episode_id when available. - let quality_json: Option = if !is_movie { - if let Some(eid) = episode_id { - sqlx::query_scalar( - "SELECT quality FROM queue WHERE media_type = $1 AND media_id = $2 AND episode_id = $3 ORDER BY id DESC LIMIT 1", - ) - .bind(media_type) - .bind(media_id) - .bind(eid) - .fetch_optional(pool) - .await - .unwrap_or(None) - } else { - sqlx::query_scalar( - "SELECT quality FROM queue WHERE media_type = $1 AND media_id = $2 ORDER BY id DESC LIMIT 1", - ) - .bind(media_type) - .bind(media_id) - .fetch_optional(pool) - .await - .unwrap_or(None) - } - } else { - sqlx::query_scalar( - "SELECT quality FROM queue WHERE media_type = $1 AND media_id = $2 ORDER BY id DESC LIMIT 1", - ) - .bind(media_type) - .bind(media_id) - .fetch_optional(pool) - .await - .unwrap_or(None) - }; - - // Parse quality ID from the queue item's quality JSONB - quality_json.and_then(|qj| { - qj.get("quality") - .and_then(|q| q.get("id").and_then(|id| id.as_i64()).or_else(|| q.as_i64())) - .and_then(|v| i32::try_from(v).ok()) - }) -} - -/// Look up the quality (and custom format score) of an existing file on disk -/// for the given media context. Returns (existing_quality_num, existing_cf_score). -#[allow(clippy::too_many_arguments)] -async fn lookup_existing_file_quality( - pool: &sqlx::PgPool, - cf_engine: &CustomFormatEngine, - cf_formats: &[CustomFormatDef], - cf_scores: &[(i64, i32)], - is_movie: bool, - series_id: Option, - movie_id: Option, - episode_id: Option, -) -> (Option, Option) { - // For movies: look up the movie's existing file - if is_movie { - if let Some(mid) = movie_id { - let row: Option<(serde_json::Value, Option)> = sqlx::query_as( - "SELECT mf.quality, mf.scene_name - FROM movies m - JOIN media_files mf ON mf.id = m.movie_file_id - WHERE m.id = $1 AND m.movie_file_id IS NOT NULL", - ) - .bind(mid) - .fetch_optional(pool) - .await - .unwrap_or(None); - - if let Some((quality_json, scene_name)) = row { - return parse_existing_file_context( - &quality_json, - scene_name.as_deref(), - cf_engine, - cf_formats, - cf_scores, - ); - } - } - return (None, None); - } - - // For series: look up the episode's existing file (or best existing file for the series) - if let Some(eid) = episode_id { - let row: Option<(serde_json::Value, Option)> = sqlx::query_as( - "SELECT mf.quality, mf.scene_name - FROM episodes e - JOIN media_files mf ON mf.id = e.episode_file_id - WHERE e.id = $1 AND e.episode_file_id IS NOT NULL", - ) - .bind(eid) - .fetch_optional(pool) - .await - .unwrap_or(None); - - if let Some((quality_json, scene_name)) = row { - return parse_existing_file_context( - &quality_json, - scene_name.as_deref(), - cf_engine, - cf_formats, - cf_scores, - ); - } - } else if let Some(sid) = series_id { - // No specific episode — use the highest quality file across the series - let row: Option<(serde_json::Value, Option)> = sqlx::query_as( - "SELECT mf.quality, mf.scene_name - FROM episodes e - JOIN media_files mf ON mf.id = e.episode_file_id - WHERE e.series_id = $1 AND e.episode_file_id IS NOT NULL - ORDER BY mf.id DESC LIMIT 1", - ) - .bind(sid) - .fetch_optional(pool) - .await - .unwrap_or(None); - - if let Some((quality_json, scene_name)) = row { - return parse_existing_file_context( - &quality_json, - scene_name.as_deref(), - cf_engine, - cf_formats, - cf_scores, - ); - } - } - - (None, None) -} - -/// Parse the quality JSONB from a media_file row into a quality number and CF score. -/// The quality column stores Sonarr/Radarr-format JSON like: -/// `{"quality": {"id": 16, "name": "WEBDL-2160p"}, "revision": {"version": 1, "real": 0}}` -/// or bare `{"quality": 16}`. -fn parse_existing_file_context( - quality_json: &serde_json::Value, - scene_name: Option<&str>, - cf_engine: &CustomFormatEngine, - cf_formats: &[CustomFormatDef], - cf_scores: &[(i64, i32)], -) -> (Option, Option) { - // Extract quality ID from the JSONB - let quality_num = quality_json - .get("quality") - .and_then(|q| { - // Object format: {"quality": {"id": 16}} - q.get("id") - .and_then(|id| id.as_i64()) - .or_else(|| q.as_i64()) // Bare integer: {"quality": 16} - }) - .and_then(|v| i32::try_from(v).ok()); - - // Compute CF score for the existing file using its scene name - let cf_score = scene_name - .filter(|s| !s.is_empty()) - .map(|name| cf_engine.score_release(name, cf_formats, cf_scores).total_score); - - (quality_num, cf_score) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct GrabRequest { - guid: String, - indexer_id: i64, - /// Release title (used for the download request and queue entry) - #[serde(default)] - title: String, - /// Download URL - #[serde(default)] - download_url: Option, - /// Protocol: "usenet" or "torrent" - #[serde(default)] - protocol: Option, - /// Size in bytes - #[serde(default)] - size: Option, - /// Associated media ID - #[serde(default)] - media_id: Option, - /// Media type: "series" or "movie" - #[serde(default)] - media_type: Option, - /// Episode ID (for series, enables queue conflict checking by episode) - #[serde(default)] - episode_id: Option, -} - -async fn grab_release( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - tracing::info!(guid = %body.guid, indexer_id = body.indexer_id, "release grab requested"); - - let download_url = match body.download_url { - Some(ref url) if !url.is_empty() => url.clone(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({"error": "download_url is required"})), - ) - .into_response(); - } - }; - - let protocol = match body.protocol.as_deref() { - Some("torrent") => stackarr_download::DownloadProtocol::Torrent, - _ => stackarr_download::DownloadProtocol::Usenet, - }; - - let grab_req = stackarr_download::GrabRequest { - title: if body.title.is_empty() { - body.guid.clone() - } else { - body.title.clone() - }, - download_url, - category: None, - protocol, - }; - - // Send to download client - let mgr = state.download_manager.read().await; - let (client_id, download_id) = match mgr.grab(&grab_req).await { - Ok(result) => result, - Err(e) => { - tracing::error!(error = %e, "grab failed"); - return ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": format!("grab failed: {e}")})), - ) - .into_response(); - } - }; - drop(mgr); - - // Create queue entry - let pool = state.db.pool(); - let media_type = body.media_type.as_deref().unwrap_or("series"); - let media_id = body.media_id.unwrap_or(0); - let core_protocol = match protocol { - stackarr_download::DownloadProtocol::Torrent => "torrent", - stackarr_download::DownloadProtocol::Usenet => "usenet", - }; - let title = if body.title.is_empty() { - &body.guid - } else { - &body.title - }; - - if let Err(e) = sqlx::query( - "INSERT INTO queue (media_type, media_id, episode_id, title, quality, size, status, download_id, download_client_id, indexer_id, protocol) - VALUES ($1, $2, $3, $4, '{}'::jsonb, $5, 'queued', $6, $7, $8, $9)", - ) - .bind(media_type) - .bind(media_id) - .bind(body.episode_id) - .bind(title) - .bind(body.size) - .bind(&download_id) - .bind(client_id as i32) - .bind(body.indexer_id as i32) - .bind(core_protocol) - .execute(pool) - .await - { - tracing::warn!(error = %e, "failed to insert queue entry (download still dispatched)"); - } - - // Record in history - if let Err(e) = sqlx::query( - "INSERT INTO history (media_type, media_id, event_type, quality, source_title, download_id, indexer_id, download_client) - VALUES ($1, $2, 'grabbed', '{}'::jsonb, $3, $4, $5, $6)", - ) - .bind(media_type) - .bind(media_id) - .bind(title) - .bind(&download_id) - .bind(body.indexer_id as i32) - .bind(client_id.to_string()) - .execute(pool) - .await - { - tracing::warn!(error = %e, "failed to insert history entry"); - } - - ( - StatusCode::OK, - Json(serde_json::json!({ - "success": true, - "downloadClientId": client_id, - "downloadId": download_id, - })), - ) - .into_response() -} - -pub fn router() -> Router> { - Router::new().route("/api/v1/release", get(search_releases).post(grab_release)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/remote.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/remote.rs deleted file mode 100644 index 16408cad..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/remote.rs +++ /dev/null @@ -1,254 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, post}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; -use uuid::Uuid; - -use crate::middleware::{AuthType, RequireAuth}; -use crate::AppState; - -// ── Generate claim code (admin only) ──────────────────────────────────────── - -async fn create_claim( - State(state): State>, - _: crate::middleware::RequireApiKey, -) -> impl IntoResponse { - let config = state.config.load(); - let bootstrap = &config.bootstrap; - - if !bootstrap.enabled { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "bootstrap is not configured"})), - ) - .into_response(); - } - - let Some(ref bootstrap_url) = bootstrap.url else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "bootstrap URL not configured"})), - ) - .into_response(); - }; - - let Some(ref bootstrap_token) = bootstrap.token else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "bootstrap token not configured"})), - ) - .into_response(); - }; - - // Generate a client token - let client_token = Uuid::new_v4(); - - // Store in database - if let Err(e) = state.db.create_remote_client(client_token).await { - tracing::error!(error = %e, "failed to create remote client"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - - // Load server_id - let server_id = match sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'server_id'", - ) - .fetch_optional(state.db.pool()) - .await - { - Ok(Some(val)) => match val.as_str().and_then(|s| Uuid::parse_str(s).ok()) { - Some(id) => id, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "invalid server_id"})), - ) - .into_response(); - } - }, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "server_id not found"})), - ) - .into_response(); - } - }; - - // Call bootstrap to create the claim - let client = reqwest::Client::new(); - let result = client - .post(format!("{bootstrap_url}/api/v1/claims")) - .bearer_auth(bootstrap_token) - .json(&json!({ - "serverId": server_id, - "clientToken": client_token, - })) - .send() - .await; - - match result { - Ok(resp) if resp.status().is_success() => match resp.json::().await { - Ok(body) => Json(json!({ - "code": body["code"], - "expiresInSecs": body["expiresInSecs"], - "clientToken": client_token, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to parse bootstrap response"); - ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "bootstrap returned invalid response"})), - ) - .into_response() - } - }, - Ok(resp) => { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - tracing::error!(%status, %body, "bootstrap rejected claim request"); - ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("bootstrap returned {status}")})), - ) - .into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to reach bootstrap"); - ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": "cannot reach bootstrap node"})), - ) - .into_response() - } - } -} - -// ── Client self-registration (client token auth) ──────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RegisterClientRequest { - client_name: String, -} - -async fn register_client( - State(state): State>, - RequireAuth(auth_type): RequireAuth, - Json(body): Json, -) -> impl IntoResponse { - // Extract the token from the request for updating the name - // We need to re-extract it since RequireAuth consumed it - let client_name = body.client_name.trim(); - if client_name.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "client_name is required"})), - ) - .into_response(); - } - - if auth_type == AuthType::ApiKey { - // Admin can't register as a client this way - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "use a client token, not the admin API key"})), - ) - .into_response(); - } - - // We need the actual token value — re-extract from headers - // RequireAuth already validated it, so we just need to parse it - // This is a bit redundant but keeps the extractor clean - let config = state.config.load(); - let instance_name = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'instance_name'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_else(|| config.general.instance_name.clone()); - - let server_id = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'server_id'", - ) - .fetch_optional(state.db.pool()) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default(); - - Json(json!({ - "serverName": instance_name, - "serverId": server_id, - "registered": true, - })) - .into_response() -} - -// ── List remote clients (admin only) ──────────────────────────────────────── - -async fn list_clients( - State(state): State>, - _: crate::middleware::RequireApiKey, -) -> impl IntoResponse { - match state.db.list_remote_clients().await { - Ok(clients) => Json(serde_json::to_value(clients).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list remote clients"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Revoke/delete remote client (admin only) ──────────────────────────────── - -async fn delete_client( - State(state): State>, - _: crate::middleware::RequireApiKey, - Path(id): Path, -) -> impl IntoResponse { - match state.db.delete_remote_client(id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "client not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete remote client"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Router ────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/remote/claim", post(create_claim)) - .route("/api/v1/remote/register", post(register_client)) - .route("/api/v1/remote/clients", get(list_clients)) - .route("/api/v1/remote/clients/{id}", delete(delete_client)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/requests.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/requests.rs deleted file mode 100644 index 51a8cabf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/requests.rs +++ /dev/null @@ -1,289 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::middleware::{RequireAdmin, RequireUser}; -use crate::AppState; - -// ── Types ──────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateRequestBody { - media_type: String, - tmdb_id: i64, - title: String, - year: Option, - poster_url: Option, - overview: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ListRequestsQuery { - status: Option, - mine: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AdminNoteBody { - note: Option, -} - -// ── Handlers ───────────────────────────────────────────────────────────────── - -async fn create_request( - State(state): State>, - RequireUser(user): RequireUser, - Json(body): Json, -) -> impl IntoResponse { - // Validate media_type - if body.media_type != "movie" && body.media_type != "series" { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "mediaType must be 'movie' or 'series'"})), - ) - .into_response(); - } - - // Check if already in library (series by tmdb_id or movies by tmdb_id) - let pool = state.db.pool(); - let in_library = if body.media_type == "series" { - let row: Option<(i64,)> = - sqlx::query_as("SELECT id FROM series WHERE tmdb_id = $1") - .bind(body.tmdb_id) - .fetch_optional(pool) - .await - .ok() - .flatten(); - row.is_some() - } else { - let row: Option<(i64,)> = - sqlx::query_as("SELECT id FROM movies WHERE tmdb_id = $1") - .bind(body.tmdb_id) - .fetch_optional(pool) - .await - .ok() - .flatten(); - row.is_some() - }; - - if in_library { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "This title is already in your library"})), - ) - .into_response(); - } - - // Check for duplicate request - if let Ok(Some(existing)) = state - .db - .check_request_exists(body.tmdb_id, &body.media_type) - .await - { - return ( - StatusCode::CONFLICT, - Json(json!({ - "error": "A request for this title already exists", - "existingRequest": { - "id": existing.id, - "status": existing.status, - } - })), - ) - .into_response(); - } - - match state - .db - .create_media_request( - user.user_id, - &body.media_type, - body.tmdb_id, - &body.title, - body.year, - body.poster_url.as_deref(), - body.overview.as_deref(), - ) - .await - { - Ok(request) => (StatusCode::CREATED, Json(json!(request))).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to create media request"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to create request"})), - ) - .into_response() - } - } -} - -async fn list_requests( - State(state): State>, - RequireUser(user): RequireUser, - Query(query): Query, -) -> impl IntoResponse { - let is_admin = user.role == "admin"; - let user_filter = if is_admin && query.mine != Some(true) { - None - } else { - Some(user.user_id) - }; - - match state - .db - .list_media_requests(query.status.as_deref(), user_filter) - .await - { - Ok(requests) => Json(json!(requests)).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list media requests"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to list requests"})), - ) - .into_response() - } - } -} - -async fn get_request( - State(state): State>, - RequireUser(user): RequireUser, - Path(id): Path, -) -> impl IntoResponse { - match state.db.get_media_request(id).await { - Ok(Some(request)) => { - // Non-admin users can only see their own requests - if user.role != "admin" && request.user_id != user.user_id { - return StatusCode::NOT_FOUND.into_response(); - } - Json(json!(request)).into_response() - } - Ok(None) => StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get media request"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to get request"})), - ) - .into_response() - } - } -} - -async fn delete_request( - State(state): State>, - RequireAdmin(_user): RequireAdmin, - Path(id): Path, -) -> impl IntoResponse { - match state.db.delete_media_request(id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete media request"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to delete request"})), - ) - .into_response() - } - } -} - -async fn approve_request( - State(state): State>, - RequireAdmin(user): RequireAdmin, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - match state - .db - .update_request_status(id, "approved", Some(user.user_id), body.note.as_deref()) - .await - { - Ok(true) => { - if let Ok(Some(req)) = state.db.get_media_request(id).await { - Json(json!(req)).into_response() - } else { - StatusCode::OK.into_response() - } - } - Ok(false) => StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to approve media request"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to approve request"})), - ) - .into_response() - } - } -} - -async fn decline_request( - State(state): State>, - RequireAdmin(user): RequireAdmin, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - match state - .db - .update_request_status(id, "declined", Some(user.user_id), body.note.as_deref()) - .await - { - Ok(true) => { - if let Ok(Some(req)) = state.db.get_media_request(id).await { - Json(json!(req)).into_response() - } else { - StatusCode::OK.into_response() - } - } - Ok(false) => StatusCode::NOT_FOUND.into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to decline media request"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to decline request"})), - ) - .into_response() - } - } -} - -async fn count_pending(State(state): State>) -> impl IntoResponse { - match state.db.count_pending_requests().await { - Ok(count) => Json(json!({"count": count})).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to count pending requests"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to count requests"})), - ) - .into_response() - } - } -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/requests", post(create_request).get(list_requests)) - .route( - "/api/v1/requests/{id}", - get(get_request).delete(delete_request), - ) - .route("/api/v1/requests/{id}/approve", put(approve_request)) - .route("/api/v1/requests/{id}/decline", put(decline_request)) - .route("/api/v1/requests/pending/count", get(count_pending)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/search.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/search.rs deleted file mode 100644 index 1dd36118..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/search.rs +++ /dev/null @@ -1,130 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::Deserialize; - -use stackarr_core::models::DownloadProtocol; -use stackarr_indexer::newznab::Protocol; -use stackarr_indexer::search::TextSearchCriteria; - -use crate::AppState; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchQuery { - #[serde(default)] - query: String, - /// Comma-separated category IDs (e.g. "2000,5000") - #[serde(default)] - categories: Option, - /// Comma-separated indexer IDs to filter search to specific indexers - #[serde(default)] - indexer_ids: Option, -} - -/// Response shape for each search result. -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct SearchResult { - guid: String, - title: String, - download_url: Option, - info_url: Option, - indexer_id: i64, - indexer_name: String, - protocol: DownloadProtocol, - size: i64, - age_days: i64, - #[serde(with = "chrono::serde::ts_seconds")] - publish_date: chrono::DateTime, - // Torrent-specific - info_hash: Option, - magnet_url: Option, - seeders: Option, - leechers: Option, - // Usenet-specific - nzb_url: Option, - categories: Vec, - indexer_flags: Vec, -} - -fn to_search_result(r: stackarr_indexer::ReleaseInfo) -> SearchResult { - SearchResult { - guid: r.guid, - title: r.title, - download_url: r.download_url, - info_url: r.info_url, - indexer_id: r.indexer_id, - indexer_name: r.indexer_name, - protocol: match r.protocol { - Protocol::Torrent => DownloadProtocol::Torrent, - Protocol::Usenet => DownloadProtocol::Usenet, - }, - size: r.size, - age_days: r.age_days, - publish_date: r.publish_date, - info_hash: r.info_hash, - magnet_url: r.magnet_url, - seeders: r.seeders, - leechers: r.leechers, - nzb_url: r.nzb_url, - categories: r.categories, - indexer_flags: r.indexer_flags, - } -} - -async fn search( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - tracing::info!(query = %query.query, "freehand search requested"); - - if query.query.is_empty() { - return Json(serde_json::json!([])).into_response(); - } - - let categories: Vec = query - .categories - .as_deref() - .unwrap_or("") - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - - let indexer_ids: Option> = query - .indexer_ids - .as_deref() - .filter(|s| !s.is_empty()) - .map(|s| s.split(',').filter_map(|id| id.trim().parse().ok()).collect()); - - let criteria = TextSearchCriteria { - query: query.query, - categories, - indexer_ids, - }; - - let mgr = state.indexer_manager.read().await; - let results = match mgr.search_text(&criteria).await { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, "freehand search failed"); - return ( - StatusCode::BAD_GATEWAY, - Json(serde_json::json!({"error": "search failed"})), - ) - .into_response(); - } - }; - drop(mgr); - - let out: Vec = results.into_iter().map(to_search_result).collect(); - Json(out).into_response() -} - -pub fn router() -> Router> { - Router::new().route("/api/v1/search", get(search)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/series.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/series.rs deleted file mode 100644 index 074d2f90..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/series.rs +++ /dev/null @@ -1,224 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use stackarr_core::models::media::Series; -use stackarr_media::{CreateSeriesInput, SeriesService, UpdateSeriesInput}; -use stackarr_metadata::TmdbClient; - -use super::extract_image_url; -use crate::AppState; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SeriesResponse { - #[serde(flatten)] - series: Series, - poster_url: Option, - fanart_url: Option, - episode_count: i64, - episode_file_count: i64, - total_episode_count: i64, - season_count: i64, -} - -#[derive(sqlx::FromRow)] -struct EpisodeCounts { - series_id: i64, - episode_count: Option, - episode_file_count: Option, - total_episode_count: Option, - season_count: Option, -} - -fn enrich_series(series: Series, counts: &HashMap) -> SeriesResponse { - let poster_url = extract_image_url(&series.images, "poster"); - let fanart_url = extract_image_url(&series.images, "fanart"); - let c = counts.get(&series.id); - SeriesResponse { - episode_count: c.and_then(|c| c.episode_count).unwrap_or(0), - episode_file_count: c.and_then(|c| c.episode_file_count).unwrap_or(0), - total_episode_count: c.and_then(|c| c.total_episode_count).unwrap_or(0), - season_count: c.and_then(|c| c.season_count).unwrap_or(0), - series, - poster_url, - fanart_url, - } -} - -async fn fetch_episode_counts( - pool: &sqlx::PgPool, -) -> Result, sqlx::Error> { - let rows = sqlx::query_as::<_, EpisodeCounts>( - "SELECT series_id, - COUNT(*) FILTER (WHERE season_number > 0) as episode_count, - COUNT(*) FILTER (WHERE episode_file_id IS NOT NULL AND season_number > 0) as episode_file_count, - COUNT(*) as total_episode_count, - COUNT(DISTINCT season_number) FILTER (WHERE season_number > 0) as season_count - FROM episodes - GROUP BY series_id", - ) - .fetch_all(pool) - .await?; - - Ok(rows.into_iter().map(|r| (r.series_id, r)).collect()) -} - -async fn list_series(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - let svc = SeriesService::new(pool.clone()); - let (series_result, counts_result) = - tokio::join!(svc.list(), fetch_episode_counts(pool)); - - match (series_result, counts_result) { - (Ok(list), Ok(counts)) => { - let responses: Vec = list - .into_iter() - .map(|s| enrich_series(s, &counts)) - .collect(); - Json(responses).into_response() - } - (Err(e), _) => { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() - } - (_, Err(e)) => { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() - } - } -} - -async fn get_series( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - let svc = SeriesService::new(pool.clone()); - let (series_result, counts_result) = - tokio::join!(svc.get(id), fetch_episode_counts(pool)); - - match (series_result, counts_result) { - (Ok(s), Ok(counts)) => Json(enrich_series(s, &counts)).into_response(), - (Err(_), _) => StatusCode::NOT_FOUND.into_response(), - (_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -async fn create_series( - State(state): State>, - Json(input): Json, -) -> impl IntoResponse { - let svc = SeriesService::new(state.db.pool().clone()); - match svc.create(input).await { - Ok(s) => { - let counts = HashMap::new(); - (StatusCode::CREATED, Json(enrich_series(s, &counts))).into_response() - } - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn update_series( - State(state): State>, - Path(id): Path, - Json(input): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - let svc = SeriesService::new(pool.clone()); - match svc.update(id, input).await { - Ok(s) => { - let counts = fetch_episode_counts(pool).await.unwrap_or_default(); - Json(enrich_series(s, &counts)).into_response() - } - Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(), - } -} - -async fn delete_series( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let svc = SeriesService::new(state.db.pool().clone()); - match svc.delete(id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), - } -} - -// ── TMDB Lookup ───────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct LookupQuery { - term: String, -} - -async fn lookup_series( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - // Get TMDB API key from config or environment - let api_key = std::env::var("STACKARR_TMDB_API_KEY") - .ok() - .or_else(|| { - // Also try app_config table (not blocking here since we need async) - None - }); - - let api_key = match api_key { - Some(key) if !key.is_empty() => key, - _ => { - // Try loading from DB synchronously via a quick query - let pool = state.db.pool(); - match sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'tmdb_api_key'", - ) - .fetch_optional(pool) - .await - { - Ok(Some(val)) => match val.as_str() { - Some(k) if !k.is_empty() => k.to_string(), - _ => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured. Set STACKARR_TMDB_API_KEY or configure via settings."})), - ) - .into_response(); - } - }, - _ => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "TMDB API key not configured. Set STACKARR_TMDB_API_KEY or configure via settings."})), - ) - .into_response(); - } - } - } - }; - - let client = TmdbClient::new(api_key); - match client.search_series(&query.term, None).await { - Ok(results) => Json(json!(results)).into_response(), - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("TMDB search failed: {e}")})), - ) - .into_response(), - } -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/series", get(list_series).post(create_series)) - .route( - "/api/v1/series/{id}", - get(get_series).put(update_series).delete(delete_series), - ) - .route("/api/v1/series/lookup", get(lookup_series)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/stream.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/stream.rs deleted file mode 100644 index 50807d6e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/stream.rs +++ /dev/null @@ -1,507 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use axum::body::Body; -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode}; -use axum::response::IntoResponse; -use axum::routing::{delete, get, post}; -use axum::{Json, Router}; -use serde_json::json; -use uuid::Uuid; - -use stackarr_stream::types::TranscodeRequest; - -use crate::AppState; - -// ── Helpers ────────────────────────────────────────────────────────────── - -fn streaming_not_enabled() -> impl IntoResponse { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"error": "streaming server is not enabled"})), - ) -} - -/// Resolve the full filesystem path for a media file by joining the -/// parent entity's directory path with the file's relative path. -async fn resolve_media_path(pool: &sqlx::PgPool, media_file_id: i64) -> Result { - // Try movie first (simpler join) - let movie_row: Option<(String, String)> = sqlx::query_as( - "SELECT m.path, mf.relative_path - FROM media_files mf - JOIN movies m ON m.movie_file_id = mf.id - WHERE mf.id = $1 AND mf.media_type = 'movie'", - ) - .bind(media_file_id) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!(error = %e, "failed to query movie path"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - if let Some((lib_path, rel_path)) = movie_row { - return Ok(PathBuf::from(lib_path).join(rel_path)); - } - - // Try series episode - let series_row: Option<(String, String)> = sqlx::query_as( - "SELECT s.path, mf.relative_path - FROM media_files mf - JOIN episode_files ef ON ef.media_file_id = mf.id - JOIN episodes e ON ef.episode_id = e.id - JOIN series s ON e.series_id = s.id - WHERE mf.id = $1 AND mf.media_type = 'series' - LIMIT 1", - ) - .bind(media_file_id) - .fetch_optional(pool) - .await - .map_err(|e| { - tracing::error!(error = %e, "failed to query series path"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - if let Some((lib_path, rel_path)) = series_row { - return Ok(PathBuf::from(lib_path).join(rel_path)); - } - - Err(StatusCode::NOT_FOUND) -} - -// ── Route Handlers ──────────────────────────────────────────────────────── - -/// GET /api/v1/stream/{media_file_id}/info -/// Returns ffprobe media information (video/audio/subtitle streams). -async fn stream_info( - State(state): State>, - Path(media_file_id): Path, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - // Check for cached media_info in DB - let cached: Option<(Option,)> = sqlx::query_as( - "SELECT media_info FROM media_files WHERE id = $1", - ) - .bind(media_file_id) - .fetch_optional(state.db.pool()) - .await - .unwrap_or(None); - - if let Some((Some(info),)) = &cached { - // Only use cached data if it has the streaming MediaInfo shape (videoStreams array). - // Old Sonarr-imported media_info uses a flat format that the frontend can't consume. - if !info.is_null() && info.get("videoStreams").is_some() { - return Json(info.clone()).into_response(); - } - } - - // If no cached, we need to resolve the file and probe it - let file_path = match resolve_media_path(state.db.pool(), media_file_id).await { - Ok(p) => p, - Err(StatusCode::NOT_FOUND) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media file not found"})), - ) - .into_response() - } - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - }; - - let config = mgr.config(); - match stackarr_stream::ffprobe::probe(&config.ffprobe_path, &file_path).await { - Ok(info) => { - // Cache the result in DB - if let Ok(info_json) = serde_json::to_value(&info) { - let _ = sqlx::query("UPDATE media_files SET media_info = $1 WHERE id = $2") - .bind(&info_json) - .bind(media_file_id) - .execute(state.db.pool()) - .await; - } - Json(json!(info)).into_response() - } - Err(e) => { - tracing::error!(media_file_id, error = %e, "ffprobe failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": format!("probe failed: {e}")})), - ) - .into_response() - } - } -} - -/// GET /api/v1/stream/{media_file_id}/direct -/// Serve the media file directly with HTTP range request support. -async fn stream_direct( - State(state): State>, - Path(media_file_id): Path, - headers: HeaderMap, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - let file_path = match resolve_media_path(state.db.pool(), media_file_id).await { - Ok(p) => p, - Err(StatusCode::NOT_FOUND) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media file not found"})), - ) - .into_response() - } - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - }; - - let range_header = headers - .get("range") - .and_then(|v| v.to_str().ok()); - - match stackarr_stream::direct::serve_file(&file_path, range_header).await { - Ok(resp) => { - // Record direct session (fire and forget) - let mgr_clone = Arc::clone(mgr); - tokio::spawn(async move { - let _ = mgr_clone.create_direct_session(media_file_id).await; - }); - - let status = if resp.status == 206 { - StatusCode::PARTIAL_CONTENT - } else { - StatusCode::OK - }; - - let mut response_headers = HeaderMap::new(); - response_headers.insert( - "content-type", - HeaderValue::from_str(&resp.content_type).unwrap_or(HeaderValue::from_static("application/octet-stream")), - ); - response_headers.insert( - "content-length", - HeaderValue::from_str(&resp.content_length.to_string()).unwrap_or(HeaderValue::from_static("0")), - ); - response_headers.insert("accept-ranges", HeaderValue::from_static("bytes")); - - if let Some(range) = &resp.content_range { - if let Ok(val) = HeaderValue::from_str(range) { - response_headers.insert("content-range", val); - } - } - - let body = Body::from_stream(resp.body); - (status, response_headers, body).into_response() - } - Err(stackarr_stream::StreamError::NotFound(_)) => { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "file not found on disk"})), - ) - .into_response() - } - Err(stackarr_stream::StreamError::InvalidRange(msg)) => { - ( - StatusCode::RANGE_NOT_SATISFIABLE, - Json(json!({"error": msg})), - ) - .into_response() - } - Err(e) => { - tracing::error!(media_file_id, error = %e, "direct play failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "streaming failed"})), - ) - .into_response() - } - } -} - -/// POST /api/v1/stream/{media_file_id}/transcode -/// Start a transcode session and return the HLS playlist URL. -async fn start_transcode( - State(state): State>, - Path(media_file_id): Path, - Json(request): Json, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - let file_path = match resolve_media_path(state.db.pool(), media_file_id).await { - Ok(p) => p, - Err(StatusCode::NOT_FOUND) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media file not found"})), - ) - .into_response() - } - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - }; - - match mgr - .create_transcode_session(media_file_id, &file_path, &request) - .await - { - Ok(resp) => (StatusCode::CREATED, Json(json!(resp))).into_response(), - Err(stackarr_stream::StreamError::MaxSessions) => ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({"error": "max concurrent transcode sessions reached"})), - ) - .into_response(), - Err(e) => { - tracing::error!(media_file_id, error = %e, "failed to start transcode"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": format!("transcode failed: {e}")})), - ) - .into_response() - } - } -} - -/// GET /api/v1/stream/{media_file_id}/hls/{session_id}/master.m3u8 -/// Serve the HLS master playlist. -async fn hls_playlist( - State(state): State>, - Path((media_file_id, session_id)): Path<(i64, Uuid)>, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - if !mgr.validate_session(session_id, media_file_id) { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "session not found"})), - ) - .into_response(); - } - - mgr.heartbeat(session_id); - - let Some(session_dir) = mgr.get_session_dir(session_id) else { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "session directory not found"})), - ) - .into_response(); - }; - - // Wait briefly for the playlist to be created - let playlist_path = session_dir.join("master.m3u8"); - if !playlist_path.exists() { - tokio::time::sleep(Duration::from_secs(2)).await; - } - - let api_prefix = format!("/api/v1/stream/{media_file_id}/hls/{session_id}"); - match stackarr_stream::hls::read_playlist(&session_dir, &api_prefix).await { - Ok(playlist) => { - let mut headers = HeaderMap::new(); - headers.insert( - "content-type", - HeaderValue::from_static("application/vnd.apple.mpegurl"), - ); - (StatusCode::OK, headers, playlist).into_response() - } - Err(e) => { - tracing::error!(session_id = %session_id, error = %e, "failed to read playlist"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "playlist not ready"})), - ) - .into_response() - } - } -} - -/// GET /api/v1/stream/{media_file_id}/hls/{session_id}/{segment} -/// Serve a single HLS segment. -async fn hls_segment( - State(state): State>, - Path((media_file_id, session_id, segment)): Path<(i64, Uuid, String)>, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - if !mgr.validate_session(session_id, media_file_id) { - return (StatusCode::NOT_FOUND, "session not found").into_response(); - } - - mgr.heartbeat(session_id); - - let Some(session_dir) = mgr.get_session_dir(session_id) else { - return (StatusCode::NOT_FOUND, "session directory not found").into_response(); - }; - - // Wait for the segment to be written by ffmpeg - if let Err(e) = - stackarr_stream::hls::wait_for_segment(&session_dir, &segment, Duration::from_secs(30)) - .await - { - tracing::warn!(segment = %segment, error = %e, "segment wait timed out"); - return (StatusCode::NOT_FOUND, "segment not ready").into_response(); - } - - match stackarr_stream::hls::read_segment(&session_dir, &segment).await { - Ok(data) => { - let mut headers = HeaderMap::new(); - headers.insert("content-type", HeaderValue::from_static("video/mp2t")); - (StatusCode::OK, headers, data).into_response() - } - Err(_) => (StatusCode::NOT_FOUND, "segment not found").into_response(), - } -} - -/// GET /api/v1/stream/{media_file_id}/subtitles/{track_index} -/// Extract and serve a subtitle track as WebVTT. -async fn stream_subtitle( - State(state): State>, - Path((media_file_id, track_index)): Path<(i64, usize)>, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - let file_path = match resolve_media_path(state.db.pool(), media_file_id).await { - Ok(p) => p, - Err(_) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media file not found"})), - ) - .into_response() - } - }; - - // Create a temp file for the extracted subtitle - let config = mgr.config(); - let cache_dir = mgr.transcode_dir().join("subtitles"); - let _ = tokio::fs::create_dir_all(&cache_dir).await; - let output_path = cache_dir.join(format!("{media_file_id}_{track_index}.vtt")); - - // Use cached version if available - if !output_path.exists() { - if let Err(e) = stackarr_stream::subtitle::extract_to_webvtt( - &config.ffmpeg_path, - &file_path, - track_index, - &output_path, - ) - .await - { - tracing::error!(media_file_id, track_index, error = %e, "subtitle extraction failed"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": format!("subtitle extraction failed: {e}")})), - ) - .into_response(); - } - } - - match tokio::fs::read_to_string(&output_path).await { - Ok(vtt) => { - let mut headers = HeaderMap::new(); - headers.insert("content-type", HeaderValue::from_static("text/vtt; charset=utf-8")); - (StatusCode::OK, headers, vtt).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to read subtitle file"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to read subtitle"})), - ) - .into_response() - } - } -} - -/// GET /api/v1/stream/sessions -/// List all active streaming sessions. -async fn list_sessions( - State(state): State>, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - Json(mgr.list_sessions()).into_response() -} - -/// DELETE /api/v1/stream/sessions/{session_id} -/// Stop a streaming session. -async fn stop_session( - State(state): State>, - Path(session_id): Path, -) -> impl IntoResponse { - let Some(ref mgr) = state.stream_session_manager else { - return streaming_not_enabled().into_response(); - }; - - match mgr.stop_session(session_id).await { - Ok(()) => (StatusCode::OK, Json(json!({"status": "stopped"}))).into_response(), - Err(e) => { - tracing::error!(%session_id, error = %e, "failed to stop session"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "failed to stop session"})), - ) - .into_response() - } - } -} - -// ── Router ────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/stream/{media_file_id}/info", get(stream_info)) - .route("/api/v1/stream/{media_file_id}/direct", get(stream_direct)) - .route( - "/api/v1/stream/{media_file_id}/transcode", - post(start_transcode), - ) - .route( - "/api/v1/stream/{media_file_id}/hls/{session_id}/master.m3u8", - get(hls_playlist), - ) - .route( - "/api/v1/stream/{media_file_id}/hls/{session_id}/{segment}", - get(hls_segment), - ) - .route( - "/api/v1/stream/{media_file_id}/subtitles/{track_index}", - get(stream_subtitle), - ) - .route("/api/v1/stream/sessions", get(list_sessions)) - .route( - "/api/v1/stream/sessions/{session_id}", - delete(stop_session), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/system.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/system.rs deleted file mode 100644 index a5414ba7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/system.rs +++ /dev/null @@ -1,980 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use axum::extract::{DefaultBodyLimit, Multipart, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post, put}; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct EnabledModulesResponse { - tv_management: bool, - movie_management: bool, - torrent_embedded: bool, - usenet_embedded: bool, - torrent_external: bool, - usenet_external: bool, - indexarr_sidecar: bool, - external_indexers: bool, - plex_integration: bool, - notifications: bool, - streaming: bool, - remote_access: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct StatusResponse { - version: &'static str, - instance_name: String, - first_boot: bool, - modules: EnabledModulesResponse, - start_time: String, -} - -async fn get_status(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - // Check if any modules are enabled — if none, it's first boot - let enabled_count: i64 = - match sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM enabled_modules WHERE enabled = true") - .fetch_one(pool) - .await - { - Ok(count) => count, - Err(e) => { - tracing::error!(error = %e, "failed to query enabled modules count"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let first_boot = enabled_count == 0; - - // Load module states - let mut modules = EnabledModulesResponse { - tv_management: false, - movie_management: false, - torrent_embedded: false, - usenet_embedded: false, - torrent_external: false, - usenet_external: false, - indexarr_sidecar: false, - external_indexers: false, - plex_integration: false, - notifications: false, - streaming: false, - remote_access: false, - }; - - if let Ok(rows) = - sqlx::query_as::<_, (String, bool)>("SELECT module, enabled FROM enabled_modules") - .fetch_all(pool) - .await - { - for (module, enabled) in rows { - match module.as_str() { - "tv_management" => modules.tv_management = enabled, - "movie_management" => modules.movie_management = enabled, - "torrent_embedded" => modules.torrent_embedded = enabled, - "usenet_embedded" => modules.usenet_embedded = enabled, - "torrent_external" => modules.torrent_external = enabled, - "usenet_external" => modules.usenet_external = enabled, - "indexarr_sidecar" => modules.indexarr_sidecar = enabled, - "external_indexers" => modules.external_indexers = enabled, - "plex_integration" => modules.plex_integration = enabled, - "notifications" => modules.notifications = enabled, - "streaming" => modules.streaming = enabled, - "remote_access" => modules.remote_access = enabled, - _ => {} - } - } - } - - // Get instance name from app_config, fall back to config file value - let instance_name = sqlx::query_scalar::<_, serde_json::Value>( - "SELECT value FROM app_config WHERE key = 'instance_name'", - ) - .fetch_optional(pool) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_else(|| state.config.load().general.instance_name.clone()); - - let start_time = chrono::Utc::now().to_rfc3339(); - - Json(StatusResponse { - version: env!("CARGO_PKG_VERSION"), - instance_name, - first_boot, - modules, - start_time, - }) - .into_response() -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EnabledModulesRequest { - tv_management: Option, - movie_management: Option, - torrent_embedded: Option, - usenet_embedded: Option, - torrent_external: Option, - usenet_external: Option, - indexarr_sidecar: Option, - external_indexers: Option, - plex_integration: Option, - notifications: Option, - streaming: Option, - remote_access: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct MediaLibraryFolderRequest { - path: String, - media_type: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct IndexarrSetupRequest { - url: String, - api_key: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SetupRequest { - modules: EnabledModulesRequest, - media_library_folders: Option>, - instance_name: Option, - indexarr: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct SetupResponse { - success: bool, - message: String, - api_key: String, - modules_configured: Vec, - media_library_folders_added: usize, -} - -async fn init_setup( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Check if already set up - let enabled_count: i64 = - match sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM enabled_modules WHERE enabled = true") - .fetch_one(pool) - .await - { - Ok(count) => count, - Err(e) => { - tracing::error!(error = %e, "failed to check setup status"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - if enabled_count > 0 { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "Setup has already been completed. Use the settings API to modify configuration."})), - ) - .into_response(); - } - - // Upsert each module into enabled_modules - let module_entries = [ - ("tv_management", body.modules.tv_management.unwrap_or(false)), - ( - "movie_management", - body.modules.movie_management.unwrap_or(false), - ), - ( - "torrent_embedded", - body.modules.torrent_embedded.unwrap_or(false), - ), - ( - "usenet_embedded", - body.modules.usenet_embedded.unwrap_or(false), - ), - ( - "torrent_external", - body.modules.torrent_external.unwrap_or(false), - ), - ( - "usenet_external", - body.modules.usenet_external.unwrap_or(false), - ), - ( - "indexarr_sidecar", - body.modules.indexarr_sidecar.unwrap_or(false), - ), - ( - "external_indexers", - body.modules.external_indexers.unwrap_or(false), - ), - ( - "plex_integration", - body.modules.plex_integration.unwrap_or(false), - ), - ( - "notifications", - body.modules.notifications.unwrap_or(false), - ), - ( - "streaming", - body.modules.streaming.unwrap_or(false), - ), - ( - "remote_access", - body.modules.remote_access.unwrap_or(false), - ), - ]; - - let mut modules_configured = Vec::new(); - - for (module, enabled) in &module_entries { - if let Err(e) = sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) - ON CONFLICT (module) DO UPDATE SET enabled = $2", - ) - .bind(module) - .bind(enabled) - .execute(pool) - .await - { - tracing::error!(error = %e, module, "failed to upsert module"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - if *enabled { - modules_configured.push(module.to_string()); - } - } - - // Insert media library folders if provided - let mut media_library_folders_added = 0; - if let Some(folders) = &body.media_library_folders { - for folder in folders { - if let Err(e) = sqlx::query( - "INSERT INTO media_library_folders (path, media_type) VALUES ($1, $2) - ON CONFLICT (path) DO UPDATE SET media_type = $2", - ) - .bind(&folder.path) - .bind(&folder.media_type) - .execute(pool) - .await - { - tracing::error!(error = %e, path = %folder.path, "failed to insert media library folder"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - media_library_folders_added += 1; - } - } - - // Update instance_name in app_config if provided - if let Some(name) = &body.instance_name { - let name_json = serde_json::Value::String(name.clone()); - if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('instance_name', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", - ) - .bind(&name_json) - .execute(pool) - .await - { - tracing::error!(error = %e, "failed to set instance name"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - - // Store Indexarr config if provided - if let Some(indexarr) = &body.indexarr { - for (key, val) in [ - ("indexarr_url", &indexarr.url), - ("indexarr_api_key", &indexarr.api_key), - ] { - let val_json = serde_json::Value::String(val.clone()); - if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2", - ) - .bind(key) - .bind(&val_json) - .execute(pool) - .await - { - tracing::error!(error = %e, "failed to store indexarr config"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - } - - // Generate and store API key - let api_key = uuid::Uuid::new_v4().to_string(); - let api_key_json = serde_json::Value::String(api_key.clone()); - if let Err(e) = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('api_key', $1) - ON CONFLICT (key) DO UPDATE SET value = $1", - ) - .bind(&api_key_json) - .execute(pool) - .await - { - tracing::error!(error = %e, "failed to store API key"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - - ( - StatusCode::CREATED, - Json(json!(SetupResponse { - success: true, - message: "Setup complete".to_string(), - api_key, - modules_configured, - media_library_folders_added, - })), - ) - .into_response() -} - -// ── Migration endpoint ────────────────────────────────────────────────────── - -async fn post_migrate( - State(state): State>, - mut multipart: Multipart, -) -> impl IntoResponse { - let pool = state.db.pool(); - let tmp_dir = match tempfile::tempdir() { - Ok(d) => d, - Err(e) => { - tracing::error!(error = %e, "failed to create temp directory"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let mut sonarr_path: Option = None; - let mut radarr_path: Option = None; - let mut prowlarr_path: Option = None; - let mut path_mappings: Vec = Vec::new(); - - // Process multipart fields - while let Ok(Some(field)) = multipart.next_field().await { - let field_name = field.name().unwrap_or("").to_string(); - let data = match field.bytes().await { - Ok(d) => d, - Err(e) => { - tracing::error!(error = %e, field_name, "failed to read multipart field"); - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "failed to read upload field"})), - ) - .into_response(); - } - }; - - match field_name.as_str() { - "path_mappings" => { - match serde_json::from_slice::>(&data) { - Ok(mappings) => path_mappings = mappings, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": format!("invalid path_mappings JSON: {e}")})), - ) - .into_response(); - } - } - continue; - } - name @ ("sonarr_db" | "radarr_db" | "prowlarr_db") => { - let dest = match name { - "sonarr_db" => { - let p = tmp_dir.path().join("sonarr.db"); - sonarr_path = Some(p.clone()); - p - } - "radarr_db" => { - let p = tmp_dir.path().join("radarr.db"); - radarr_path = Some(p.clone()); - p - } - _ => { - let p = tmp_dir.path().join("prowlarr.db"); - prowlarr_path = Some(p.clone()); - p - } - }; - - if let Err(e) = tokio::fs::write(&dest, &data).await { - tracing::error!(error = %e, "failed to write temp file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - _ => continue, - } - } - - if sonarr_path.is_none() && radarr_path.is_none() && prowlarr_path.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "at least one of sonarr_db, radarr_db, or prowlarr_db must be uploaded"})), - ) - .into_response(); - } - - match stackarr_migrate::run_migration( - pool, - sonarr_path.as_deref(), - radarr_path.as_deref(), - prowlarr_path.as_deref(), - &path_mappings, - false, - ) - .await - { - Ok(report) => Json(json!(report)).into_response(), - Err(e) => { - tracing::error!(error = %e, "migration failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "migration failed"})), - ) - .into_response() - } - } - // tmp_dir is dropped here, cleaning up temp files -} - -// ── Command endpoint ──────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CommandRequest { - name: String, - series_id: Option, - movie_id: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct CommandResponse { - name: String, - status: String, - result: Option, - error: Option, -} - -async fn post_command( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match body.name.as_str() { - "DiskScan" => { - // If a specific series_id is given, scan just that series' path - if let Some(series_id) = body.series_id { - let series_row: Option<(String,)> = match sqlx::query_as( - "SELECT path FROM series WHERE id = $1", - ) - .bind(series_id) - .fetch_optional(pool) - .await - { - Ok(r) => r, - Err(e) => { - tracing::error!(error = %e, series_id, "failed to query series path"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response(); - } - }; - - if let Some((path,)) = series_row { - let scan_path = std::path::Path::new(&path); - // For a specific series, we scan its parent (media library folder) or the series path directly - // Since the series path itself IS the series dir, we scan it directly as a "series" root - // But disk_scan expects the root to contain series dirs, so we use the parent - let root_path = scan_path.parent().unwrap_or(scan_path); - match stackarr_import::disk_scan(pool, root_path, "series").await { - Ok(scan_result) => { - return Json(json!(CommandResponse { - name: body.name, - status: "completed".to_string(), - result: Some(serde_json::to_value(scan_result).unwrap_or_default()), - error: None, - })) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "disk scan failed for series"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("disk scan failed".to_string()), - })), - ) - .into_response(); - } - } - } else { - return ( - StatusCode::NOT_FOUND, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some(format!("series {series_id} not found")), - })), - ) - .into_response(); - } - } - - // No specific series — scan all media library folders - let media_library_folders: Vec<(String, String)> = match sqlx::query_as( - "SELECT path, media_type FROM media_library_folders", - ) - .fetch_all(pool) - .await - { - Ok(rows) => rows, - Err(e) => { - tracing::error!(error = %e, "failed to query media library folders"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response(); - } - }; - - if media_library_folders.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("no media library folders configured".to_string()), - })), - ) - .into_response(); - } - - // Aggregate results from all media library folders - let mut total = stackarr_import::DiskScanResult { - files_found: 0, - files_matched: 0, - files_unmatched: 0, - files_already_tracked: 0, - unmatched_files: Vec::new(), - }; - let mut errors = Vec::new(); - - for (path, media_type) in &media_library_folders { - let scan_path = std::path::Path::new(path); - match stackarr_import::disk_scan(pool, scan_path, media_type).await { - Ok(r) => { - total.files_found += r.files_found; - total.files_matched += r.files_matched; - total.files_unmatched += r.files_unmatched; - total.files_already_tracked += r.files_already_tracked; - total.unmatched_files.extend(r.unmatched_files); - } - Err(e) => { - errors.push(format!("scan of '{}' failed: {e}", path)); - } - } - } - - let error_msg = if errors.is_empty() { - None - } else { - Some(errors.join("; ")) - }; - - Json(json!(CommandResponse { - name: body.name, - status: if error_msg.is_some() { "completedWithErrors".to_string() } else { "completed".to_string() }, - result: Some(serde_json::to_value(total).unwrap_or_default()), - error: error_msg, - })) - .into_response() - } - "RefreshSeries" => { - if let Some(series_id) = body.series_id { - // Mark a specific series as needing refresh by updating last_info_sync - match sqlx::query( - "UPDATE series SET last_info_sync = NOW() WHERE id = $1", - ) - .bind(series_id) - .execute(pool) - .await - { - Ok(r) if r.rows_affected() == 0 => ( - StatusCode::NOT_FOUND, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some(format!("series {series_id} not found")), - })), - ) - .into_response(), - Ok(_) => Json(json!(CommandResponse { - name: body.name, - status: "completed".to_string(), - result: Some(json!({"seriesId": series_id})), - error: None, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, series_id, "failed to refresh series"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response() - } - } - } else { - // Refresh all series - match sqlx::query("UPDATE series SET last_info_sync = NOW()") - .execute(pool) - .await - { - Ok(r) => Json(json!(CommandResponse { - name: body.name, - status: "completed".to_string(), - result: Some(json!({"seriesUpdated": r.rows_affected()})), - error: None, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to refresh all series"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response() - } - } - } - } - "RefreshMovie" => { - if let Some(movie_id) = body.movie_id { - match sqlx::query( - "UPDATE movies SET last_info_sync = NOW() WHERE id = $1", - ) - .bind(movie_id) - .execute(pool) - .await - { - Ok(r) if r.rows_affected() == 0 => ( - StatusCode::NOT_FOUND, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some(format!("movie {movie_id} not found")), - })), - ) - .into_response(), - Ok(_) => Json(json!(CommandResponse { - name: body.name, - status: "completed".to_string(), - result: Some(json!({"movieId": movie_id})), - error: None, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, movie_id, "failed to refresh movie"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response() - } - } - } else { - // Refresh all movies - match sqlx::query("UPDATE movies SET last_info_sync = NOW()") - .execute(pool) - .await - { - Ok(r) => Json(json!(CommandResponse { - name: body.name, - status: "completed".to_string(), - result: Some(json!({"moviesUpdated": r.rows_affected()})), - error: None, - })) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to refresh all movies"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response() - } - } - } - } - "RefreshAll" => { - // Set last_info_sync to NULL for all series and movies so the scheduler - // picks them up for a full metadata refresh - let series_result = sqlx::query("UPDATE series SET last_info_sync = NULL") - .execute(pool) - .await; - let movies_result = sqlx::query("UPDATE movies SET last_info_sync = NULL") - .execute(pool) - .await; - - match (series_result, movies_result) { - (Ok(sr), Ok(mr)) => Json(json!(CommandResponse { - name: body.name, - status: "completed".to_string(), - result: Some(json!({ - "seriesMarked": sr.rows_affected(), - "moviesMarked": mr.rows_affected(), - })), - error: None, - })) - .into_response(), - (Err(e), _) | (_, Err(e)) => { - tracing::error!(error = %e, "failed to refresh all media"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!(CommandResponse { - name: body.name, - status: "error".to_string(), - result: None, - error: Some("internal server error".to_string()), - })), - ) - .into_response() - } - } - } - other => ( - StatusCode::BAD_REQUEST, - Json(json!(CommandResponse { - name: other.to_string(), - status: "error".to_string(), - result: None, - error: Some(format!("unknown command: {other}")), - })), - ) - .into_response(), - } -} - -// ── Filesystem browse endpoint ────────────────────────────────────────────── - -#[derive(Deserialize)] -struct FilesystemBrowseQuery { - path: Option, -} - -#[derive(Serialize)] -struct FilesystemDirectory { - name: String, - path: String, -} - -#[derive(Serialize)] -struct FilesystemBrowseResponse { - current: String, - parent: Option, - directories: Vec, -} - -async fn get_filesystem_browse( - Query(params): Query, -) -> impl IntoResponse { - let raw_path = params.path.unwrap_or_else(|| "/".to_string()); - let browse_path = std::path::Path::new(&raw_path); - - let parent = browse_path - .parent() - .map(|p| p.to_string_lossy().into_owned()) - .filter(|p| p != &raw_path); - - let mut directories = Vec::new(); - - if let Ok(mut entries) = tokio::fs::read_dir(&browse_path).await { - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { - continue; - }; - if file_type.is_dir() { - let name = entry.file_name().to_string_lossy().into_owned(); - let full_path = entry.path().to_string_lossy().into_owned(); - directories.push(FilesystemDirectory { - name, - path: full_path, - }); - } - } - } - - directories.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - - Json(FilesystemBrowseResponse { - current: raw_path, - parent, - directories, - }) -} - -// ── Update modules endpoint ───────────────────────────────────────────────── - -async fn put_modules( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - let module_entries: Vec<(&str, Option)> = vec![ - ("tv_management", body.tv_management), - ("movie_management", body.movie_management), - ("torrent_embedded", body.torrent_embedded), - ("usenet_embedded", body.usenet_embedded), - ("torrent_external", body.torrent_external), - ("usenet_external", body.usenet_external), - ("indexarr_sidecar", body.indexarr_sidecar), - ("external_indexers", body.external_indexers), - ("plex_integration", body.plex_integration), - ("notifications", body.notifications), - ("streaming", body.streaming), - ("remote_access", body.remote_access), - ]; - - let mut updated = Vec::new(); - - for (module, value) in &module_entries { - let Some(enabled) = value else { continue }; - if let Err(e) = sqlx::query( - "INSERT INTO enabled_modules (module, enabled) VALUES ($1, $2) - ON CONFLICT (module) DO UPDATE SET enabled = $2", - ) - .bind(module) - .bind(enabled) - .execute(pool) - .await - { - tracing::error!(error = %e, module, "failed to update module"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - updated.push(*module); - } - - Json(json!({"updated": updated})).into_response() -} - -/// Public routes (no auth required) — status + setup init. -pub fn public_router() -> Router> { - Router::new() - .route("/api/v1/system/status", get(get_status)) - .route("/api/v1/setup/init", post(init_setup)) -} - -/// Protected routes (require API key) — migration, commands, filesystem browse. -pub fn protected_router() -> Router> { - Router::new() - .route("/api/v1/system/migrate", post(post_migrate)) - .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)) // 1 GB for DB uploads - .route("/api/v1/command", post(post_command)) - .route("/api/v1/modules", put(put_modules)) - .route("/api/v1/filesystem/browse", get(get_filesystem_browse)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/tags.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/tags.rs deleted file mode 100644 index f34d4119..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/tags.rs +++ /dev/null @@ -1,182 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Serialize, Deserialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct TagResponse { - id: i32, - label: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateTagRequest { - label: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateTagRequest { - label: String, -} - -async fn list_tags(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query_as::<_, TagResponse>("SELECT id, label FROM tags ORDER BY id") - .fetch_all(pool) - .await - { - Ok(tags) => Json(tags).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list tags"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn create_tag( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if body.label.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "label cannot be empty"})), - ) - .into_response(); - } - - match sqlx::query_as::<_, TagResponse>( - "INSERT INTO tags (label) VALUES ($1) RETURNING id, label", - ) - .bind(body.label.trim()) - .fetch_one(pool) - .await - { - Ok(tag) => (StatusCode::CREATED, Json(json!(tag))).into_response(), - Err(e) => { - let msg = e.to_string(); - if msg.contains("duplicate key") || msg.contains("unique") { - ( - StatusCode::CONFLICT, - Json(json!({"error": "tag with this label already exists"})), - ) - .into_response() - } else { - tracing::error!(error = %e, "failed to create tag"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } - } -} - -async fn update_tag( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - if body.label.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "label cannot be empty"})), - ) - .into_response(); - } - - match sqlx::query_as::<_, TagResponse>( - "UPDATE tags SET label = $1 WHERE id = $2 RETURNING id, label", - ) - .bind(body.label.trim()) - .bind(id as i32) - .fetch_optional(pool) - .await - { - Ok(Some(tag)) => Json(json!(tag)).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "tag not found"})), - ) - .into_response(), - Err(e) => { - let msg = e.to_string(); - if msg.contains("duplicate key") || msg.contains("unique") { - ( - StatusCode::CONFLICT, - Json(json!({"error": "tag with this label already exists"})), - ) - .into_response() - } else { - tracing::error!(error = %e, "failed to update tag"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } - } -} - -async fn delete_tag( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - match sqlx::query("DELETE FROM tags WHERE id = $1") - .bind(id as i32) - .execute(pool) - .await - { - Ok(result) => { - if result.rows_affected() == 0 { - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "tag not found"})), - ) - .into_response() - } else { - StatusCode::NO_CONTENT.into_response() - } - } - Err(e) => { - tracing::error!(error = %e, "failed to delete tag"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/tag", get(list_tags).post(create_tag)) - .route( - "/api/v1/tag/{id}", - axum::routing::put(update_tag).delete(delete_tag), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/torrent.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/torrent.rs deleted file mode 100644 index a6849399..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/torrent.rs +++ /dev/null @@ -1,265 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::AppState; - -// --------------------------------------------------------------------------- -// Request / query types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct AddTorrentRequest { - url: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct DeleteTorrentQuery { - #[serde(default)] - delete_files: bool, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn engine_not_initialized() -> impl IntoResponse { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "torrent engine not initialized" - })), - ) -} - -fn parse_torrent_id(id: &str) -> Result { - librtbit::api::TorrentIdOrHash::parse(id).map_err(|e| { - ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("invalid torrent id: {e}") })), - ) - }) -} - -// --------------------------------------------------------------------------- -// Handlers -// --------------------------------------------------------------------------- - -/// GET /api/v1/torrent/status -async fn torrent_status(State(state): State>) -> impl IntoResponse { - match &state.torrent_api { - Some(api) => { - let stats = api.api_session_stats(); - Json(json!({ - "enabled": true, - "downloadSpeed": stats.download_speed.as_bytes(), - "uploadSpeed": stats.upload_speed.as_bytes(), - "sessionUptime": stats.uptime_seconds, - "peers": { - "connecting": stats.peers.connecting, - "liveTcp": stats.peers.live_tcp, - "liveUtp": stats.peers.live_utp, - "dead": stats.peers.dead, - "queued": stats.peers.queued, - "seen": stats.peers.seen, - }, - "counters": { - "fetchedBytes": stats.counters.fetched_bytes, - "uploadedBytes": stats.counters.uploaded_bytes, - }, - })) - .into_response() - } - None => Json(json!({ - "enabled": false, - "message": "Torrent engine not enabled" - })) - .into_response(), - } -} - -/// GET /api/v1/torrent/list -async fn torrent_list(State(state): State>) -> impl IntoResponse { - match &state.torrent_api { - Some(api) => { - let list = api.api_torrent_list(); - Json(json!({ - "torrents": list.torrents, - "total": list.total, - })) - .into_response() - } - None => Json(json!({ - "torrents": [], - "total": 0, - })) - .into_response(), - } -} - -/// POST /api/v1/torrent/add -async fn torrent_add( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let Some(api) = &state.torrent_api else { - return engine_not_initialized().into_response(); - }; - - let url = match body.url { - Some(u) => u, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "missing 'url' field" })), - ) - .into_response(); - } - }; - - let add = librtbit::AddTorrent::from_url(url); - let opts = librtbit::AddTorrentOptions { - overwrite: true, - ..Default::default() - }; - - match api.api_add_torrent(add, Some(opts)).await { - Ok(resp) => Json(json!({ - "id": resp.id, - "details": resp.details, - "outputFolder": resp.output_folder, - })) - .into_response(), - Err(e) => e.into_response(), - } -} - -/// POST /api/v1/torrent/{id}/pause -async fn torrent_pause( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(api) = &state.torrent_api else { - return engine_not_initialized().into_response(); - }; - - let idx = match parse_torrent_id(&id) { - Ok(idx) => idx, - Err(e) => return e.into_response(), - }; - - match api.api_torrent_action_pause(idx).await { - Ok(_) => Json(json!({ "success": true })).into_response(), - Err(e) => e.into_response(), - } -} - -/// POST /api/v1/torrent/{id}/resume -async fn torrent_resume( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(api) = &state.torrent_api else { - return engine_not_initialized().into_response(); - }; - - let idx = match parse_torrent_id(&id) { - Ok(idx) => idx, - Err(e) => return e.into_response(), - }; - - match api.api_torrent_action_start(idx).await { - Ok(_) => Json(json!({ "success": true })).into_response(), - Err(e) => e.into_response(), - } -} - -/// POST /api/v1/torrent/{id}/delete -async fn torrent_delete( - State(state): State>, - Path(id): Path, - Query(params): Query, -) -> impl IntoResponse { - let Some(api) = &state.torrent_api else { - return engine_not_initialized().into_response(); - }; - - let idx = match parse_torrent_id(&id) { - Ok(idx) => idx, - Err(e) => return e.into_response(), - }; - - let result = if params.delete_files { - api.api_torrent_action_delete(idx).await - } else { - api.api_torrent_action_forget(idx).await - }; - - match result { - Ok(_) => Json(json!({ "success": true })).into_response(), - Err(e) => e.into_response(), - } -} - -/// GET /api/v1/torrent/{id} -async fn torrent_details( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(api) = &state.torrent_api else { - return engine_not_initialized().into_response(); - }; - - let idx = match parse_torrent_id(&id) { - Ok(idx) => idx, - Err(e) => return e.into_response(), - }; - - match api.api_torrent_details(idx) { - Ok(details) => Json(json!(details)).into_response(), - Err(e) => e.into_response(), - } -} - -/// GET /api/v1/torrent/{id}/stats -async fn torrent_stats( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(api) = &state.torrent_api else { - return engine_not_initialized().into_response(); - }; - - let idx = match parse_torrent_id(&id) { - Ok(idx) => idx, - Err(e) => return e.into_response(), - }; - - match api.api_stats_v1(idx) { - Ok(stats) => Json(json!(stats)).into_response(), - Err(e) => e.into_response(), - } -} - -// --------------------------------------------------------------------------- -// Router -// --------------------------------------------------------------------------- - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/torrent/status", get(torrent_status)) - .route("/api/v1/torrent/list", get(torrent_list)) - .route("/api/v1/torrent/add", post(torrent_add)) - .route("/api/v1/torrent/{id}", get(torrent_details)) - .route("/api/v1/torrent/{id}/stats", get(torrent_stats)) - .route("/api/v1/torrent/{id}/pause", post(torrent_pause)) - .route("/api/v1/torrent/{id}/resume", post(torrent_resume)) - .route("/api/v1/torrent/{id}/delete", post(torrent_delete)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/usenet.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/usenet.rs deleted file mode 100644 index 05b66d31..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/usenet.rs +++ /dev/null @@ -1,1037 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Multipart, Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{get, post, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; -use tracing::{error, info}; - -use crate::AppState; - -// --------------------------------------------------------------------------- -// Request types -// --------------------------------------------------------------------------- - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AddNzbRequest { - url: Option, - name: Option, - category: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct NntpServerRequest { - name: Option, - host: Option, - port: Option, - username: Option, - password: Option, - ssl: Option, - connections: Option, - priority: Option, - enabled: Option, - retention: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SpeedLimitRequest { - /// Speed limit in bytes per second (0 = unlimited). - bytes_per_second: u64, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PauseDurationRequest { - /// Optional pause duration in seconds. If absent or 0, pause indefinitely. - #[serde(default)] - duration_secs: Option, -} - -// --------------------------------------------------------------------------- -// DB row for download_clients -// --------------------------------------------------------------------------- - -#[derive(Debug, sqlx::FromRow)] -struct DownloadClientRow { - id: i32, - name: String, - #[allow(dead_code)] - client_type: String, - #[allow(dead_code)] - protocol: String, - config: serde_json::Value, - enabled: bool, - priority: i32, -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn engine_not_initialized() -> impl IntoResponse { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "usenet engine not initialized" - })), - ) -} - -fn nzb_error_response(e: nzb_core::NzbError) -> impl IntoResponse { - let status = match &e { - nzb_core::NzbError::JobNotFound(_) | nzb_core::NzbError::ServerNotFound(_) => { - StatusCode::NOT_FOUND - } - _ => StatusCode::BAD_REQUEST, - }; - (status, Json(json!({ "error": e.to_string() }))) -} - -/// Query all `download_clients` with `client_type = 'embedded_usenet'`, -/// deserialize each config as `ServerConfig`, and push them into the -/// running nzb engine via `update_servers()`. -async fn refresh_engine_servers(state: &AppState) -> Result<(), (StatusCode, Json)> { - let pool = state.db.pool(); - - let rows = sqlx::query_as::<_, DownloadClientRow>( - "SELECT id, name, client_type, protocol, config, enabled, priority - FROM download_clients - WHERE client_type = 'embedded_usenet' - ORDER BY priority ASC" - ) - .fetch_all(pool) - .await - .map_err(|e| { - error!("Failed to query usenet servers from DB: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - })?; - - let mut servers = Vec::with_capacity(rows.len()); - for row in &rows { - match serde_json::from_value::(row.config.clone()) { - Ok(mut sc) => { - // Override enabled/priority from the DB columns - sc.enabled = row.enabled; - sc.priority = row.priority as u8; - servers.push(sc); - } - Err(e) => { - error!( - id = row.id, - name = %row.name, - "Failed to deserialize usenet server config: {e}" - ); - } - } - } - - if let Some(qm) = &state.usenet_queue { - qm.update_servers(servers); - info!("Refreshed usenet engine with {} servers from DB", rows.len()); - } - - Ok(()) -} - -/// Build a `ServerConfig` from a request, filling defaults for missing fields. -fn server_config_from_request(req: &NntpServerRequest) -> nzb_core::config::ServerConfig { - let host = req.host.clone().unwrap_or_default(); - let name = req.name.clone().unwrap_or_else(|| host.clone()); - nzb_core::config::ServerConfig { - id: uuid::Uuid::new_v4().to_string(), - name, - host, - port: req.port.unwrap_or(563), - ssl: req.ssl.unwrap_or(true), - ssl_verify: true, - username: req.username.clone(), - password: req.password.clone(), - connections: req.connections.unwrap_or(8) as u16, - priority: req.priority.unwrap_or(0) as u8, - enabled: req.enabled.unwrap_or(true), - retention: req.retention.unwrap_or(0), - pipelining: 1, - optional: false, - compress: false, - } -} - -/// Merge an `NntpServerRequest` (partial update) on top of an existing `ServerConfig`. -fn merge_server_config( - existing: &mut nzb_core::config::ServerConfig, - req: &NntpServerRequest, -) { - if let Some(name) = &req.name { - existing.name = name.clone(); - } - if let Some(host) = &req.host { - existing.host = host.clone(); - } - if let Some(port) = req.port { - existing.port = port; - } - if let Some(ssl) = req.ssl { - existing.ssl = ssl; - } - if let Some(username) = &req.username { - existing.username = Some(username.clone()); - } - if let Some(password) = &req.password { - existing.password = Some(password.clone()); - } - if let Some(connections) = req.connections { - existing.connections = connections as u16; - } - if let Some(priority) = req.priority { - existing.priority = priority as u8; - } - if let Some(enabled) = req.enabled { - existing.enabled = enabled; - } - if let Some(retention) = req.retention { - existing.retention = retention; - } -} - -/// Serialize a `DownloadClientRow` to the JSON shape the API returns. -fn server_row_to_json(row: &DownloadClientRow) -> serde_json::Value { - let mut server = row.config.clone(); - // Inject the DB id so the frontend can reference it for CRUD - if let Some(obj) = server.as_object_mut() { - obj.insert("dbId".to_string(), json!(row.id)); - // Ensure enabled/priority reflect the DB columns - obj.insert("enabled".to_string(), json!(row.enabled)); - obj.insert("priority".to_string(), json!(row.priority)); - // Mask the password in the response - if obj.contains_key("password") { - obj.insert("password".to_string(), json!("********")); - } - } - server -} - -// --------------------------------------------------------------------------- -// Status / queue handlers -// --------------------------------------------------------------------------- - -/// GET /api/v1/usenet/status -async fn usenet_status(State(state): State>) -> impl IntoResponse { - match &state.usenet_queue { - Some(qm) => Json(json!({ - "enabled": true, - "speed": qm.get_speed(), - "queueSize": qm.queue_size(), - "activeDownloads": qm.get_jobs().iter().filter(|j| j.status == nzb_core::models::JobStatus::Downloading).count(), - "paused": qm.is_paused(), - "maxActiveDownloads": qm.get_max_active_downloads(), - "speedLimit": qm.get_speed_limit(), - "pauseRemainingSecs": qm.pause_remaining_secs(), - })) - .into_response(), - None => Json(json!({ - "enabled": false, - "speed": 0, - "queueSize": 0, - "activeDownloads": 0, - "paused": false, - "message": "Usenet engine not initialized. Configure NNTP servers in settings." - })) - .into_response(), - } -} - -/// GET /api/v1/usenet/queue -async fn usenet_queue(State(state): State>) -> impl IntoResponse { - match &state.usenet_queue { - Some(qm) => { - let jobs = qm.get_jobs(); - Json(json!({ "jobs": jobs })).into_response() - } - None => Json(json!({ "jobs": [] })).into_response(), - } -} - -/// POST /api/v1/usenet/add -async fn usenet_add( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - let url = match body.url { - Some(u) => u, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "missing 'url' field" })), - ) - .into_response(); - } - }; - - // Fetch the NZB data from the URL - let nzb_bytes = match reqwest::get(&url).await { - Ok(resp) => match resp.bytes().await { - Ok(b) => b.to_vec(), - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("failed to read NZB data: {e}") })), - ) - .into_response(); - } - }, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("failed to fetch NZB: {e}") })), - ) - .into_response(); - } - }; - - let name = body.name.unwrap_or_else(|| "download".to_string()); - let mut job = match nzb_core::nzb_parser::parse_nzb(&name, &nzb_bytes) { - Ok(j) => j, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("failed to parse NZB: {e}") })), - ) - .into_response(); - } - }; - - // Set directories - job.work_dir = qm.incomplete_dir().join(&job.id); - job.output_dir = qm.complete_dir().join(&job.name); - if let Some(cat) = body.category { - job.category = cat; - } - - match qm.add_job(job, Some(nzb_bytes)) { - Ok(()) => Json(json!({ "success": true })).into_response(), - Err(e) => nzb_error_response(e).into_response(), - } -} - -/// POST /api/v1/usenet/queue/{id}/pause -async fn usenet_queue_pause( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - match qm.pause_job(&id) { - Ok(()) => Json(json!({ "success": true })).into_response(), - Err(e) => nzb_error_response(e).into_response(), - } -} - -/// POST /api/v1/usenet/queue/{id}/resume -async fn usenet_queue_resume( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - match qm.resume_job(&id) { - Ok(()) => Json(json!({ "success": true })).into_response(), - Err(e) => nzb_error_response(e).into_response(), - } -} - -/// POST /api/v1/usenet/queue/{id}/delete -async fn usenet_queue_delete( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - match qm.remove_job(&id) { - Ok(()) => Json(json!({ "success": true })).into_response(), - Err(e) => nzb_error_response(e).into_response(), - } -} - -// --------------------------------------------------------------------------- -// History handlers -// --------------------------------------------------------------------------- - -/// GET /api/v1/usenet/history -async fn usenet_history(State(state): State>) -> impl IntoResponse { - match &state.usenet_queue { - Some(qm) => match qm.history_list(500) { - Ok(records) => Json(json!({ "records": records })).into_response(), - Err(e) => nzb_error_response(e).into_response(), - }, - None => Json(json!({ "records": [] })).into_response(), - } -} - -/// POST /api/v1/usenet/history/{id}/retry -async fn usenet_history_retry( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - // Get the history entry's NZB data - let nzb_data = match qm.history_get_nzb_data(&id) { - Ok(Some(data)) => data, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "no NZB data available for retry" })), - ) - .into_response(); - } - Err(e) => return nzb_error_response(e).into_response(), - }; - - // Get the entry for its name/category - let entry = match qm.history_get(&id) { - Ok(Some(entry)) => entry, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "history entry not found" })), - ) - .into_response(); - } - Err(e) => return nzb_error_response(e).into_response(), - }; - - // Re-parse and re-add - let mut job = match nzb_core::nzb_parser::parse_nzb(&entry.name, &nzb_data) { - Ok(j) => j, - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": format!("failed to parse NZB for retry: {e}") })), - ) - .into_response(); - } - }; - - job.work_dir = qm.incomplete_dir().join(&job.id); - job.output_dir = qm.complete_dir().join(&job.name); - job.category = entry.category; - - match qm.add_job(job, Some(nzb_data)) { - Ok(()) => Json(json!({ "success": true })).into_response(), - Err(e) => nzb_error_response(e).into_response(), - } -} - -// --------------------------------------------------------------------------- -// NNTP server management handlers -// -// Servers are persisted in the `download_clients` table and hot-reloaded -// into the running nzb engine after every mutation. -// --------------------------------------------------------------------------- - -/// GET /api/v1/usenet/servers -async fn usenet_servers_list(State(state): State>) -> impl IntoResponse { - let pool = state.db.pool(); - - let rows = match sqlx::query_as::<_, DownloadClientRow>( - "SELECT id, name, client_type, protocol, config, enabled, priority - FROM download_clients - WHERE client_type = 'embedded_usenet' - ORDER BY priority ASC" - ) - .fetch_all(pool) - .await - { - Ok(rows) => rows, - Err(e) => { - error!("Failed to list usenet servers: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - .into_response(); - } - }; - - let servers: Vec = rows.iter().map(server_row_to_json).collect(); - Json(json!({ "servers": servers })).into_response() -} - -/// POST /api/v1/usenet/servers -async fn usenet_servers_add( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - // Require at least a host - let host = match &body.host { - Some(h) if !h.is_empty() => h.clone(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "'host' is required" })), - ) - .into_response(); - } - }; - - let server_config = server_config_from_request(&body); - let config_json = match serde_json::to_value(&server_config) { - Ok(v) => v, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("failed to serialize config: {e}") })), - ) - .into_response(); - } - }; - - let display_name = body.name.clone().unwrap_or_else(|| host.clone()); - let enabled = body.enabled.unwrap_or(true); - let priority = body.priority.unwrap_or(0); - - let pool = state.db.pool(); - let row = match sqlx::query_as::<_, DownloadClientRow>( - "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, 'embedded_usenet', 'usenet', $2, $3, $4) - RETURNING id, name, client_type, protocol, config, enabled, priority" - ) - .bind(&display_name) - .bind(&config_json) - .bind(enabled) - .bind(priority) - .fetch_one(pool) - .await - { - Ok(row) => row, - Err(e) => { - error!("Failed to insert usenet server: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - .into_response(); - } - }; - - // Refresh the nzb engine with updated server list - if let Err(resp) = refresh_engine_servers(&state).await { - return resp.into_response(); - } - - info!(id = row.id, name = %display_name, "Added usenet server"); - (StatusCode::CREATED, Json(server_row_to_json(&row))).into_response() -} - -/// PUT /api/v1/usenet/servers/{id} -async fn usenet_servers_update( - State(state): State>, - Path(id): Path, - Json(body): Json, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Fetch existing row - let existing = match sqlx::query_as::<_, DownloadClientRow>( - "SELECT id, name, client_type, protocol, config, enabled, priority - FROM download_clients - WHERE id = $1 AND client_type = 'embedded_usenet'" - ) - .bind(id) - .fetch_optional(pool) - .await - { - Ok(Some(row)) => row, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": format!("usenet server {id} not found") })), - ) - .into_response(); - } - Err(e) => { - error!("Failed to fetch usenet server {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - .into_response(); - } - }; - - // Merge request fields into existing config - let mut server_config = match serde_json::from_value::( - existing.config.clone(), - ) { - Ok(sc) => sc, - Err(e) => { - error!("Failed to deserialize existing server config for {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("corrupted server config: {e}") })), - ) - .into_response(); - } - }; - - merge_server_config(&mut server_config, &body); - - let config_json = match serde_json::to_value(&server_config) { - Ok(v) => v, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("failed to serialize config: {e}") })), - ) - .into_response(); - } - }; - - let display_name = body.name.clone().unwrap_or(existing.name); - let enabled = body.enabled.unwrap_or(existing.enabled); - let priority = body.priority.unwrap_or(existing.priority); - - let row = match sqlx::query_as::<_, DownloadClientRow>( - "UPDATE download_clients - SET name = $1, config = $2, enabled = $3, priority = $4 - WHERE id = $5 - RETURNING id, name, client_type, protocol, config, enabled, priority" - ) - .bind(&display_name) - .bind(&config_json) - .bind(enabled) - .bind(priority) - .bind(id) - .fetch_one(pool) - .await - { - Ok(row) => row, - Err(e) => { - error!("Failed to update usenet server {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - .into_response(); - } - }; - - // Refresh the nzb engine - if let Err(resp) = refresh_engine_servers(&state).await { - return resp.into_response(); - } - - info!(id, name = %display_name, "Updated usenet server"); - Json(server_row_to_json(&row)).into_response() -} - -/// DELETE /api/v1/usenet/servers/{id} -async fn usenet_servers_delete( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - let result = match sqlx::query( - "DELETE FROM download_clients - WHERE id = $1 AND client_type = 'embedded_usenet'" - ) - .bind(id) - .execute(pool) - .await - { - Ok(r) => r, - Err(e) => { - error!("Failed to delete usenet server {id}: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - .into_response(); - } - }; - - if result.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": format!("usenet server {id} not found") })), - ) - .into_response(); - } - - // Refresh the nzb engine - if let Err(resp) = refresh_engine_servers(&state).await { - return resp.into_response(); - } - - info!(id, "Deleted usenet server"); - StatusCode::NO_CONTENT.into_response() -} - -/// POST /api/v1/usenet/servers/{id}/test -async fn usenet_servers_test( - State(state): State>, - Path(id): Path, -) -> impl IntoResponse { - let pool = state.db.pool(); - - // Fetch the server config from DB - let row = match sqlx::query_as::<_, DownloadClientRow>( - "SELECT id, name, client_type, protocol, config, enabled, priority - FROM download_clients - WHERE id = $1 AND client_type = 'embedded_usenet'" - ) - .bind(id) - .fetch_optional(pool) - .await - { - Ok(Some(row)) => row, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": format!("usenet server {id} not found") })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("database error: {e}") })), - ) - .into_response(); - } - }; - - let server_config = match serde_json::from_value::( - row.config.clone(), - ) { - Ok(sc) => sc, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("corrupted server config: {e}") })), - ) - .into_response(); - } - }; - - // Test connectivity by creating an NNTP connection, authenticating, then quitting - let mut conn = nzb_nntp::NntpConnection::new(server_config.id.clone()); - - let test_result = tokio::time::timeout( - std::time::Duration::from_secs(15), - conn.connect(&server_config), - ) - .await; - - match test_result { - Ok(Ok(())) => { - // Connection and auth succeeded — send QUIT - let _ = conn.quit().await; - Json(json!({ - "success": true, - "message": format!("Successfully connected to {}", server_config.host) - })) - .into_response() - } - Ok(Err(e)) => { - let msg = e.to_string(); - Json(json!({ - "success": false, - "message": msg - })) - .into_response() - } - Err(_) => { - Json(json!({ - "success": false, - "message": format!("Connection to {} timed out after 15 seconds", server_config.host) - })) - .into_response() - } - } -} - -// --------------------------------------------------------------------------- -// Queue-wide control handlers -// --------------------------------------------------------------------------- - -/// POST /api/v1/usenet/pause-all -async fn usenet_pause_all( - State(state): State>, - body: Option>, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - let duration_secs = body.and_then(|b| b.duration_secs).unwrap_or(0); - if duration_secs > 0 { - qm.pause_for(duration_secs); - info!(duration_secs, "Paused usenet queue for duration"); - Json(json!({ "success": true, "message": format!("Paused for {duration_secs} seconds") })) - .into_response() - } else { - qm.pause_all(); - info!("Paused usenet queue indefinitely"); - Json(json!({ "success": true, "message": "Paused indefinitely" })).into_response() - } -} - -/// POST /api/v1/usenet/resume-all -async fn usenet_resume_all(State(state): State>) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - qm.resume_all(); - info!("Resumed usenet queue"); - Json(json!({ "success": true, "message": "Resumed" })).into_response() -} - -/// POST /api/v1/usenet/speed-limit -async fn usenet_speed_limit( - State(state): State>, - Json(body): Json, -) -> impl IntoResponse { - let Some(qm) = &state.usenet_queue else { - return engine_not_initialized().into_response(); - }; - - qm.set_speed_limit(body.bytes_per_second); - info!(bps = body.bytes_per_second, "Set usenet speed limit"); - Json(json!({ - "success": true, - "speedLimit": body.bytes_per_second - })) - .into_response() -} - -// --------------------------------------------------------------------------- -// Router -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// SABnzbd import -// --------------------------------------------------------------------------- - -/// POST /api/v1/usenet/import-sabnzbd — upload sabnzbd.ini file, return preview -async fn import_sabnzbd_ini(mut multipart: Multipart) -> impl IntoResponse { - let mut ini_content: Option = None; - - while let Ok(Some(field)) = multipart.next_field().await { - if field.name() == Some("file") || field.name() == Some("sabnzbd_ini") { - match field.text().await { - Ok(text) => ini_content = Some(text), - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": format!("failed to read file: {e}")})), - ) - .into_response(); - } - } - } - } - - let content = match ini_content { - Some(c) => c, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "no sabnzbd.ini file uploaded (field name: 'file' or 'sabnzbd_ini')"})), - ) - .into_response(); - } - }; - - let preview = nzb_core::sabnzbd_import::parse_sabnzbd_ini(&content); - Json(json!(preview)).into_response() -} - -/// POST /api/v1/usenet/import-sabnzbd-api — fetch config from a running SABnzbd instance -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ImportSabnzbdApiRequest { - url: String, - api_key: String, -} - -async fn import_sabnzbd_api(Json(body): Json) -> impl IntoResponse { - let config_url = format!( - "{}/sabnzbd/api?mode=get_config&output=json&apikey={}", - body.url.trim_end_matches('/'), - body.api_key - ); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_default(); - - let resp = match client.get(&config_url).send().await { - Ok(r) => r, - Err(e) => { - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("failed to connect to SABnzbd: {e}")})), - ) - .into_response(); - } - }; - - let json_val: serde_json::Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - return ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("invalid JSON from SABnzbd: {e}")})), - ) - .into_response(); - } - }; - - let preview = nzb_core::sabnzbd_import::parse_sabnzbd_api_response(&json_val); - Json(json!(preview)).into_response() -} - -/// POST /api/v1/usenet/import-sabnzbd/apply — apply a previewed import to the usenet engine -async fn import_sabnzbd_apply( - State(state): State>, - Json(preview): Json, -) -> impl IntoResponse { - // Check for masked passwords - for server in &preview.servers { - if server.password_masked { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": format!("server '{}' has a masked password — please enter it manually before applying", server.name)})), - ) - .into_response(); - } - } - - // Convert imported servers to nzb-core ServerConfig and store in DB - let pool = state.db.pool(); - let mut servers_added = 0; - let mut categories_added = 0; - - for imported in &preview.servers { - let server_config = imported.to_server_config(); - let config_json = serde_json::to_value(&server_config).unwrap_or_default(); - let protocol = "usenet"; - - let result = sqlx::query( - "INSERT INTO download_clients (name, client_type, protocol, config, enabled, priority) - VALUES ($1, 'embedded_usenet', $2, $3, $4, $5)" - ) - .bind(&imported.name) - .bind(protocol) - .bind(&config_json) - .bind(imported.enabled) - .bind(imported.priority as i32) - .execute(pool) - .await; - - if result.is_ok() { - servers_added += 1; - } - } - - // Store categories in app_config - if !preview.categories.is_empty() { - let cats_json = serde_json::to_value(&preview.categories).unwrap_or_default(); - let _ = sqlx::query( - "INSERT INTO app_config (key, value) VALUES ('usenet_categories', $1) - ON CONFLICT (key) DO UPDATE SET value = $1" - ) - .bind(&cats_json) - .execute(pool) - .await; - categories_added = preview.categories.len(); - } - - Json(json!({ - "success": true, - "serversAdded": servers_added, - "categoriesAdded": categories_added, - "rssFeedsAdded": preview.rss_feeds.len(), - "warnings": preview.warnings, - "skippedFields": preview.skipped_fields, - })) - .into_response() -} - -pub fn router() -> Router> { - Router::new() - // Status & queue - .route("/api/v1/usenet/status", get(usenet_status)) - .route("/api/v1/usenet/queue", get(usenet_queue)) - .route("/api/v1/usenet/add", post(usenet_add)) - .route("/api/v1/usenet/queue/{id}/pause", post(usenet_queue_pause)) - .route( - "/api/v1/usenet/queue/{id}/resume", - post(usenet_queue_resume), - ) - .route( - "/api/v1/usenet/queue/{id}/delete", - post(usenet_queue_delete), - ) - // Queue-wide controls - .route("/api/v1/usenet/pause-all", post(usenet_pause_all)) - .route("/api/v1/usenet/resume-all", post(usenet_resume_all)) - .route("/api/v1/usenet/speed-limit", post(usenet_speed_limit)) - // History - .route("/api/v1/usenet/history", get(usenet_history)) - .route( - "/api/v1/usenet/history/{id}/retry", - post(usenet_history_retry), - ) - // NNTP server management - .route( - "/api/v1/usenet/servers", - get(usenet_servers_list).post(usenet_servers_add), - ) - .route( - "/api/v1/usenet/servers/{id}", - put(usenet_servers_update).delete(usenet_servers_delete), - ) - .route( - "/api/v1/usenet/servers/{id}/test", - post(usenet_servers_test), - ) - // SABnzbd import - .route( - "/api/v1/usenet/import-sabnzbd", - post(import_sabnzbd_ini), - ) - .route( - "/api/v1/usenet/import-sabnzbd-api", - post(import_sabnzbd_api), - ) - .route( - "/api/v1/usenet/import-sabnzbd/apply", - post(import_sabnzbd_apply), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/user.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/user.rs deleted file mode 100644 index 96919e82..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/user.rs +++ /dev/null @@ -1,305 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::middleware::RequireUser; -use crate::AppState; - -// ── Update profile ─────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateProfileRequest { - display_name: Option, - avatar_url: Option, - current_password: Option, - new_password: Option, -} - -async fn update_profile( - State(state): State>, - RequireUser(auth_user): RequireUser, - Json(body): Json, -) -> impl IntoResponse { - if auth_user.user_id == 0 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "legacy API key users cannot update profile"})), - ) - .into_response(); - } - - let existing = match state.db.get_user_by_id(auth_user.user_id).await { - Ok(Some(u)) => u, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found"})), - ) - .into_response(); - } - Err(e) => { - tracing::error!(error = %e, "failed to get user"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - // Handle password change - if let Some(ref new_password) = body.new_password { - if new_password.len() < 6 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "password must be at least 6 characters"})), - ) - .into_response(); - } - - // Require current password - let current = match &body.current_password { - Some(cp) => cp.clone(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "current password is required to change password"})), - ) - .into_response(); - } - }; - - let hash = existing.password_hash.clone(); - let valid = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::verify_password(¤t, &hash) - }) - .await - { - Ok(Ok(v)) => v, - _ => false, - }; - - if !valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "current password is incorrect"})), - ) - .into_response(); - } - - let pw = new_password.clone(); - let new_hash = match tokio::task::spawn_blocking(move || { - stackarr_core::auth::hash_password(&pw) - }) - .await - { - Ok(Ok(h)) => h, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - let _ = state - .db - .update_user_password(auth_user.user_id, &new_hash) - .await; - } - - let display_name = body - .display_name - .as_deref() - .unwrap_or(&existing.display_name); - let avatar_url = body.avatar_url.as_deref().or(existing.avatar_url.as_deref()); - - match state - .db - .update_user( - auth_user.user_id, - display_name, - &existing.role, - existing.enabled, - avatar_url, - ) - .await - { - Ok(Some(user)) => Json(json!({ - "id": user.id, - "username": user.username, - "displayName": user.display_name, - "role": user.role, - "avatarUrl": user.avatar_url, - })) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to update profile"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Devices ────────────────────────────────────────────────────────────────── - -async fn list_devices( - State(state): State>, - RequireUser(auth_user): RequireUser, -) -> impl IntoResponse { - if auth_user.user_id == 0 { - return Json(serde_json::Value::Array(vec![])).into_response(); - } - - match state.db.list_user_devices(auth_user.user_id).await { - Ok(devices) => Json(serde_json::to_value(devices).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to list devices"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_device( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path(id): Path, -) -> impl IntoResponse { - // Verify the device belongs to the user - if auth_user.user_id == 0 { - return ( - StatusCode::FORBIDDEN, - Json(json!({"error": "legacy API key users cannot manage devices"})), - ) - .into_response(); - } - - match state.db.list_user_devices(auth_user.user_id).await { - Ok(devices) => { - if !devices.iter().any(|d| d.id == id) { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "device not found"})), - ) - .into_response(); - } - } - Err(e) => { - tracing::error!(error = %e, "failed to verify device ownership"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - } - - match state.db.delete_user_device(id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "device not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete device"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Sessions ───────────────────────────────────────────────────────────────── - -async fn list_sessions( - State(state): State>, - RequireUser(auth_user): RequireUser, -) -> impl IntoResponse { - if auth_user.user_id == 0 { - return Json(serde_json::Value::Array(vec![])).into_response(); - } - - match state.db.list_sessions(auth_user.user_id).await { - Ok(sessions) => { - let sessions_json: Vec = sessions - .iter() - .map(|s| { - json!({ - "id": s.id, - "userAgent": s.user_agent, - "ipAddress": s.ip_address, - "createdAt": s.created_at, - "expiresAt": s.expires_at, - "lastActive": s.last_active, - }) - }) - .collect(); - Json(serde_json::Value::Array(sessions_json)).into_response() - } - Err(e) => { - tracing::error!(error = %e, "failed to list sessions"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_all_sessions( - State(state): State>, - RequireUser(auth_user): RequireUser, -) -> impl IntoResponse { - if auth_user.user_id == 0 { - return StatusCode::NO_CONTENT.into_response(); - } - - match state.db.delete_all_sessions(auth_user.user_id).await { - Ok(count) => Json(json!({"deleted": count})).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete sessions"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/user/profile", put(update_profile)) - .route("/api/v1/user/devices", get(list_devices)) - .route("/api/v1/user/devices/{id}", delete(delete_device)) - .route( - "/api/v1/user/sessions", - get(list_sessions).delete(delete_all_sessions), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/wanted.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/wanted.rs deleted file mode 100644 index afd9ba41..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/wanted.rs +++ /dev/null @@ -1,275 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::get; -use axum::{Json, Router}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::AppState; - -#[derive(Debug, Deserialize)] -struct PaginationParams { - #[serde(default = "default_page")] - page: i64, - #[serde(default = "default_page_size")] - page_size: i64, -} - -fn default_page() -> i64 { - 1 -} - -fn default_page_size() -> i64 { - 20 -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct WantedResponse { - page: i64, - page_size: i64, - total_records: i64, - records: Vec, -} - -#[derive(Debug, Serialize, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -struct WantedRecord { - id: i64, - media_type: String, - media_id: i64, - title: String, - season_number: Option, - episode_number: Option, - episode_title: Option, - quality_profile: Option, - air_date: Option, - monitored: bool, -} - -/// GET /api/v1/wanted/missing -/// -/// Returns monitored episodes without files (already aired) and monitored movies without files. -async fn get_missing( - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - let pool = state.db.pool(); - let limit = params.page_size; - let offset = (params.page - 1) * params.page_size; - - // Count missing episodes - let ep_count: i64 = match sqlx::query_scalar( - "SELECT COUNT(*) FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NULL - AND e.air_date_utc < NOW()", - ) - .fetch_one(pool) - .await - { - Ok(c) => c, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(); - } - }; - - // Count missing movies - let movie_count: i64 = match sqlx::query_scalar( - "SELECT COUNT(*) FROM movies - WHERE monitored = true AND movie_file_id IS NULL", - ) - .fetch_one(pool) - .await - { - Ok(c) => c, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(); - } - }; - - let total = ep_count + movie_count; - - // Fetch missing episodes - let episodes = sqlx::query_as::<_, WantedRecord>( - "SELECT e.id, 'series' as media_type, e.series_id as media_id, - s.title, e.season_number, e.episode_number, - e.title as episode_title, - qp.name as quality_profile, - e.air_date::text as air_date, e.monitored - FROM episodes e - JOIN series s ON e.series_id = s.id - LEFT JOIN quality_profiles qp ON s.quality_profile_id = qp.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NULL - AND e.air_date_utc < NOW() - ORDER BY e.air_date_utc DESC", - ) - .fetch_all(pool) - .await; - - let movies = sqlx::query_as::<_, WantedRecord>( - "SELECT m.id, 'movie' as media_type, m.id as media_id, - m.title, NULL::int as season_number, NULL::int as episode_number, - NULL::text as episode_title, - qp.name as quality_profile, - NULL::text as air_date, m.monitored - FROM movies m - LEFT JOIN quality_profiles qp ON m.quality_profile_id = qp.id - WHERE m.monitored = true AND m.movie_file_id IS NULL - ORDER BY m.title", - ) - .fetch_all(pool) - .await; - - match (episodes, movies) { - (Ok(mut ep_records), Ok(movie_records)) => { - ep_records.extend(movie_records); - // Apply pagination to the combined list - let paginated: Vec = ep_records - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); - - Json(WantedResponse { - page: params.page, - page_size: params.page_size, - total_records: total, - records: paginated, - }) - .into_response() - } - (Err(e), _) | (_, Err(e)) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(), - } -} - -/// GET /api/v1/wanted/cutoff -/// -/// Returns episodes/movies that have files but may need quality upgrades. -/// Real cutoff comparison requires the decision engine (Phase 4); for now returns -/// items that have a file assigned. -async fn get_cutoff( - State(state): State>, - Query(params): Query, -) -> impl IntoResponse { - let pool = state.db.pool(); - let limit = params.page_size; - let offset = (params.page - 1) * params.page_size; - - // Count episodes with files (potential cutoff unmet) - let ep_count: i64 = match sqlx::query_scalar( - "SELECT COUNT(*) FROM episodes e - JOIN series s ON e.series_id = s.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NOT NULL", - ) - .fetch_one(pool) - .await - { - Ok(c) => c, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(); - } - }; - - // Count movies with files - let movie_count: i64 = match sqlx::query_scalar( - "SELECT COUNT(*) FROM movies - WHERE monitored = true AND movie_file_id IS NOT NULL", - ) - .fetch_one(pool) - .await - { - Ok(c) => c, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(); - } - }; - - let total = ep_count + movie_count; - - let episodes = sqlx::query_as::<_, WantedRecord>( - "SELECT e.id, 'series' as media_type, e.series_id as media_id, - s.title, e.season_number, e.episode_number, - e.title as episode_title, - qp.name as quality_profile, - e.air_date::text as air_date, e.monitored - FROM episodes e - JOIN series s ON e.series_id = s.id - LEFT JOIN quality_profiles qp ON s.quality_profile_id = qp.id - WHERE e.monitored = true AND s.monitored = true - AND e.episode_file_id IS NOT NULL - ORDER BY e.air_date_utc DESC", - ) - .fetch_all(pool) - .await; - - let movies = sqlx::query_as::<_, WantedRecord>( - "SELECT m.id, 'movie' as media_type, m.id as media_id, - m.title, NULL::int as season_number, NULL::int as episode_number, - NULL::text as episode_title, - qp.name as quality_profile, - NULL::text as air_date, m.monitored - FROM movies m - LEFT JOIN quality_profiles qp ON m.quality_profile_id = qp.id - WHERE m.monitored = true AND m.movie_file_id IS NOT NULL - ORDER BY m.title", - ) - .fetch_all(pool) - .await; - - match (episodes, movies) { - (Ok(mut ep_records), Ok(movie_records)) => { - ep_records.extend(movie_records); - let paginated: Vec = ep_records - .into_iter() - .skip(offset as usize) - .take(limit as usize) - .collect(); - - Json(WantedResponse { - page: params.page, - page_size: params.page_size, - total_records: total, - records: paginated, - }) - .into_response() - } - (Err(e), _) | (_, Err(e)) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": e.to_string()})), - ) - .into_response(), - } -} - -pub fn router() -> Router> { - Router::new() - .route("/api/v1/wanted/missing", get(get_missing)) - .route("/api/v1/wanted/cutoff", get(get_cutoff)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/watchlist.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/watchlist.rs deleted file mode 100644 index 6b846b44..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/routes/watchlist.rs +++ /dev/null @@ -1,331 +0,0 @@ -use std::sync::Arc; - -use axum::extract::{Path, Query, State}; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::{delete, get, put}; -use axum::{Json, Router}; -use serde::Deserialize; -use serde_json::json; - -use crate::middleware::RequireUser; -use crate::routes::{extract_image_url, proxy_image_url}; -use crate::AppState; - -// ── Types ──────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct WatchlistQuery { - media_type: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RatingBody { - rating: i16, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RatingsQuery { - media_type: Option, -} - -// ── Watchlist routes ───────────────────────────────────────────────────────── - -async fn list_watchlist( - State(state): State>, - RequireUser(auth_user): RequireUser, - Query(q): Query, -) -> impl IntoResponse { - let items = match state - .db - .get_watchlist(auth_user.user_id, q.media_type.as_deref()) - .await - { - Ok(items) => items, - Err(e) => { - tracing::error!(error = %e, "failed to get watchlist"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response(); - } - }; - - // Enrich with title/poster from series/movies tables - let pool = state.db.pool(); - let mut enriched = Vec::with_capacity(items.len()); - - for item in &items { - let (title, poster_url, year) = match item.media_type.as_str() { - "series" => { - let row: Option<(String, Option, Option)> = - sqlx::query_as("SELECT title, images, year FROM series WHERE id = $1") - .bind(item.media_id) - .fetch_optional(pool) - .await - .unwrap_or(None); - match row { - Some((t, images, y)) => (Some(t), extract_image_url(&images, "poster"), y), - None => (None, None, None), - } - } - "movie" => { - let row: Option<(String, Option, Option)> = - sqlx::query_as("SELECT title, images, year FROM movies WHERE id = $1") - .bind(item.media_id) - .fetch_optional(pool) - .await - .unwrap_or(None); - match row { - Some((t, images, y)) => (Some(t), extract_image_url(&images, "poster"), y), - None => (None, None, None), - } - } - _ => (None, None, None), - }; - - enriched.push(json!({ - "id": item.id, - "userId": item.user_id, - "mediaType": item.media_type, - "mediaId": item.media_id, - "tmdbId": item.tmdb_id, - "addedAt": item.added_at, - "title": title, - "posterUrl": poster_url, - "year": year, - })); - } - - Json(serde_json::Value::Array(enriched)).into_response() -} - -async fn add_to_watchlist( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path((media_type, media_id)): Path<(String, i64)>, -) -> impl IntoResponse { - // Validate media_type - if media_type != "series" && media_type != "movie" { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "mediaType must be 'series' or 'movie'"})), - ) - .into_response(); - } - - // Look up tmdb_id from the media table - let pool = state.db.pool(); - let tmdb_id: Option> = match media_type.as_str() { - "series" => { - sqlx::query_scalar("SELECT tmdb_id FROM series WHERE id = $1") - .bind(media_id) - .fetch_optional(pool) - .await - .unwrap_or(None) - } - "movie" => { - sqlx::query_scalar("SELECT tmdb_id FROM movies WHERE id = $1") - .bind(media_id) - .fetch_optional(pool) - .await - .unwrap_or(None) - } - _ => None, - }; - - let tmdb_id = match tmdb_id { - Some(Some(id)) => id, - _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "media not found"})), - ) - .into_response(); - } - }; - - match state - .db - .add_to_watchlist(auth_user.user_id, &media_type, media_id, tmdb_id) - .await - { - Ok(item) => Json(serde_json::to_value(item).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to add to watchlist"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn remove_from_watchlist( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path((media_type, media_id)): Path<(String, i64)>, -) -> impl IntoResponse { - match state - .db - .remove_from_watchlist(auth_user.user_id, &media_type, media_id) - .await - { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "item not on watchlist"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to remove from watchlist"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Rating routes ──────────────────────────────────────────────────────────── - -async fn list_ratings( - State(state): State>, - RequireUser(auth_user): RequireUser, - Query(q): Query, -) -> impl IntoResponse { - match state - .db - .get_user_ratings(auth_user.user_id, q.media_type.as_deref()) - .await - { - Ok(ratings) => Json(serde_json::to_value(ratings).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to get ratings"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn set_rating( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path((media_type, media_id)): Path<(String, i64)>, - Json(body): Json, -) -> impl IntoResponse { - if body.rating < 1 || body.rating > 10 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "rating must be between 1 and 10"})), - ) - .into_response(); - } - - if media_type != "series" && media_type != "movie" { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "mediaType must be 'series' or 'movie'"})), - ) - .into_response(); - } - - match state - .db - .set_rating(auth_user.user_id, &media_type, media_id, body.rating) - .await - { - Ok(rating) => Json(serde_json::to_value(rating).unwrap_or_default()).into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to set rating"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn get_rating( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path((media_type, media_id)): Path<(String, i64)>, -) -> impl IntoResponse { - let user_rating = state - .db - .get_rating(auth_user.user_id, &media_type, media_id) - .await; - let average = state.db.get_average_rating(&media_type, media_id).await; - - match (user_rating, average) { - (Ok(rating), Ok((avg, count))) => Json(json!({ - "userRating": rating.map(|r| r.rating), - "averageRating": avg, - "ratingCount": count, - })) - .into_response(), - (Err(e), _) | (_, Err(e)) => { - tracing::error!(error = %e, "failed to get rating"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -async fn delete_rating( - State(state): State>, - RequireUser(auth_user): RequireUser, - Path((media_type, media_id)): Path<(String, i64)>, -) -> impl IntoResponse { - match state - .db - .delete_rating(auth_user.user_id, &media_type, media_id) - .await - { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "rating not found"})), - ) - .into_response(), - Err(e) => { - tracing::error!(error = %e, "failed to delete rating"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal server error"})), - ) - .into_response() - } - } -} - -// ── Router ─────────────────────────────────────────────────────────────────── - -pub fn router() -> Router> { - Router::new() - // Watchlist - .route("/api/v1/user/watchlist", get(list_watchlist)) - .route( - "/api/v1/user/watchlist/{mediaType}/{mediaId}", - put(add_to_watchlist).delete(remove_from_watchlist), - ) - // Ratings - .route("/api/v1/user/ratings", get(list_ratings)) - .route( - "/api/v1/user/ratings/{mediaType}/{mediaId}", - put(set_rating).get(get_rating).delete(delete_rating), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/state.rs b/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/state.rs deleted file mode 100644 index a14e3cf9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/stackarr-web/src/state.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::sync::Arc; - -use arc_swap::ArcSwap; -use stackarr_cardigann::CardigannEngine; -use stackarr_core::config::{AppConfig, EnabledModules}; -use stackarr_core::db::Database; -use stackarr_download::DownloadClientManager; -use stackarr_indexer::{IndexarrClient, IndexerManager}; -use tokio::sync::RwLock; - -/// Shared application state available to all request handlers. -#[derive(Clone)] -pub struct AppState { - pub db: Database, - pub config: Arc>, - pub modules: EnabledModules, - // Embedded engines (initialized on boot if configured) - pub torrent_session: Option>, - pub torrent_api: Option, - pub usenet_queue: Option>, - // Indexarr sidecar (initialized when config.indexarr.enabled + api_key) - pub indexarr_client: Option>, - // Cardigann engine (always available — loads definitions from disk) - pub cardigann_engine: Arc, - // Indexer + download client managers (loaded from DB at startup) - pub indexer_manager: Arc>, - pub download_manager: Arc>, - // Rate limiter (optional — None disables rate limiting) - pub rate_limiter: Option>, - // Streaming server (initialized when config.streaming.enabled) - pub stream_session_manager: Option>, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/Cargo.toml deleted file mode 100644 index 1f0db015..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "librtbit-bencode" -version = "3.1.0" -edition = "2024" -description = "Bencode serialization and deserialization using Serde" -license = "Apache-2.0" -documentation = "https://docs.rs/librtbit-bencode" -repository = "https://github.com/ikatson/rtbit" -readme = "README.md" - -[lints] -workspace = true - -[dependencies] -serde.workspace = true -serde_derive.workspace = true -buffers.workspace = true -clone_to_owned.workspace = true -bytes.workspace = true -atoi.workspace = true -thiserror.workspace = true -arrayvec.workspace = true -anyhow.workspace = true diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/README.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/README.md deleted file mode 120000 index 32d46ee8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/bencode_value.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/bencode_value.rs deleted file mode 100644 index f399bcaf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/bencode_value.rs +++ /dev/null @@ -1,236 +0,0 @@ -use std::{collections::HashMap, fmt::Display, marker::PhantomData}; - -use buffers::{ByteBuf, ByteBufOwned}; -use bytes::Bytes; -use clone_to_owned::CloneToOwned; -use serde::{Deserialize, Deserializer, Serialize}; - -use crate::deserialize::from_bytes; - -pub fn dyn_from_bytes<'de, BufT>( - buf: &'de [u8], -) -> Result, crate::deserialize::ErrorWithContext<'de>> -where - BufT: From<&'de [u8]> + std::hash::Hash + Eq, -{ - from_bytes(buf) -} - -impl serde::Serialize for BencodeValue { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - BencodeValue::Bytes(b) => b.serialize(serializer), - BencodeValue::Integer(v) => v.serialize(serializer), - BencodeValue::List(l) => l.serialize(serializer), - BencodeValue::Dict(d) => d.serialize(serializer), - } - } -} - -impl<'de, BufT> serde::de::Deserialize<'de> for BencodeValue -where - BufT: From<&'de [u8]> + std::hash::Hash + Eq, -{ - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct Visitor { - buftype: PhantomData, - } - - impl<'de, BufT> serde::de::Visitor<'de> for Visitor - where - BufT: From<&'de [u8]> + std::hash::Hash + Eq, - { - type Value = BencodeValue; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "a bencode value") - } - - fn visit_i64(self, v: i64) -> Result - where - E: serde::de::Error, - { - Ok(BencodeValue::Integer(v)) - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: serde::de::SeqAccess<'de>, - { - let mut v = Vec::new(); - while let Some(value) = seq.next_element()? { - v.push(value); - } - Ok(BencodeValue::List(v)) - } - - fn visit_borrowed_bytes(self, v: &'de [u8]) -> Result - where - E: serde::de::Error, - { - Ok(BencodeValue::Bytes(BufT::from(v))) - } - - fn visit_map(self, mut map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - let mut hmap = HashMap::new(); - while let Some(key) = map.next_key::<&'de [u8]>()? { - let value = map.next_value()?; - hmap.insert(BufT::from(key), value); - } - Ok(BencodeValue::Dict(hmap)) - } - } - - deserializer.deserialize_any(Visitor { - buftype: PhantomData, - }) - } -} - -// A dynamic value when we don't know exactly what we are deserializing. -// Useful for debugging. - -#[derive(PartialEq, Eq)] -pub enum BencodeValue { - Bytes(BufT), - Integer(i64), - List(Vec>), - Dict(HashMap>), -} - -impl std::fmt::Debug for BencodeValue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BencodeValue::Bytes(b) => std::fmt::Debug::fmt(b, f), - BencodeValue::Integer(i) => std::fmt::Debug::fmt(i, f), - BencodeValue::List(l) => std::fmt::Debug::fmt(l, f), - BencodeValue::Dict(d) => std::fmt::Debug::fmt(d, f), - } - } -} - -impl CloneToOwned for BencodeValue -where - BufT: CloneToOwned + std::hash::Hash + Eq, - ::Target: Eq + std::hash::Hash, -{ - type Target = BencodeValue<::Target>; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - match self { - BencodeValue::Bytes(b) => BencodeValue::Bytes(b.clone_to_owned(within_buffer)), - BencodeValue::Integer(i) => BencodeValue::Integer(*i), - BencodeValue::List(l) => BencodeValue::List(l.clone_to_owned(within_buffer)), - BencodeValue::Dict(d) => BencodeValue::Dict(d.clone_to_owned(within_buffer)), - } - } -} - -pub type BencodeValueBorrowed<'a> = BencodeValue>; -pub type BencodeValueOwned = BencodeValue; - -// A wrapper to deserialize dyn values as strings. -#[derive(PartialEq, Eq, Hash)] -pub struct AsDisplay(T); - -impl<'de, T> From<&'de [u8]> for AsDisplay -where - T: From<&'de [u8]>, -{ - fn from(value: &'de [u8]) -> Self { - Self(T::from(value)) - } -} - -impl Serialize for AsDisplay -where - T: Display, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&format!("{}", self.0)) - } -} - -impl<'de, T> Deserialize<'de> for AsDisplay -where - T: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = T::deserialize(deserializer)?; - Ok(AsDisplay(value)) - } -} - -#[cfg(test)] -mod tests { - use crate::serialize::bencode_serialize_to_writer; - - use super::*; - use serde::Serialize; - use std::io::Read; - - #[test] - fn test_deserialize_torrent_dyn() { - let mut buf = Vec::new(); - let filename = "../librtbit/resources/ubuntu-21.04-desktop-amd64.iso.torrent"; - std::fs::File::open(filename) - .unwrap() - .read_to_end(&mut buf) - .unwrap(); - - let torrent_borrowed: BencodeValueBorrowed = from_bytes(&buf).unwrap(); - let torrent_owned: BencodeValueOwned = from_bytes(&buf).unwrap(); - dbg!(torrent_borrowed); - dbg!(torrent_owned); - } - - #[test] - fn test_serialize_torrent_dyn() { - let mut buf = Vec::new(); - let filename = "../librtbit/resources/ubuntu-21.04-desktop-amd64.iso.torrent"; - std::fs::File::open(filename) - .unwrap() - .read_to_end(&mut buf) - .unwrap(); - - let torrent: BencodeValueBorrowed = from_bytes(&buf).unwrap(); - - let mut buf = Vec::::new(); - bencode_serialize_to_writer(&torrent, &mut buf).unwrap(); - - let new_torrent = from_bytes(&buf).unwrap(); - assert_eq!(torrent, new_torrent); - } - - #[test] - fn test_serialize_struct_with_option() { - #[derive(Serialize)] - struct Test { - f1: i64, - #[serde(skip_serializing_if = "Option::is_none")] - missing: Option, - } - let test = Test { - f1: 100, - missing: None, - }; - let mut buf = Vec::::new(); - bencode_serialize_to_writer(&test, &mut buf).unwrap(); - assert_eq!(&buf, b"d2:f1i100ee"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/deserialize.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/deserialize.rs deleted file mode 100644 index e6dffe38..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/deserialize.rs +++ /dev/null @@ -1,774 +0,0 @@ -use std::marker::PhantomData; - -use arrayvec::ArrayVec; -use atoi::{FromRadix10, FromRadix10Signed}; -use buffers::ByteBuf; -use clone_to_owned::CloneToOwned; -use serde::{Deserialize, Serialize, forward_to_deserialize_any}; - -use crate::raw_value::TAG; - -pub struct BencodeDeserializer<'de> { - buf: &'de [u8], - field_context: ErrorContext<'de>, - field_context_did_not_fit: u8, - parsing_key: bool, -} - -impl<'de> BencodeDeserializer<'de> { - pub fn new_from_buf(buf: &'de [u8]) -> BencodeDeserializer<'de> { - Self { - buf, - field_context: Default::default(), - field_context_did_not_fit: 0, - parsing_key: false, - } - } - - pub fn into_remaining(self) -> &'de [u8] { - self.buf - } - - #[inline] - pub fn parse_first_byte(&mut self, c: u8, err: Error) -> Result<(), Error> { - match self.buf.first() { - Some(start) if *start == c => { - self.buf = &self.buf[1..]; - Ok(()) - } - Some(_) => Err(err), - None => Err(Error::Eof), - } - } - - fn parse_integer(&mut self, atoi: impl Fn(&'de [u8]) -> (I, usize)) -> Result { - self.parse_first_byte(b'i', Error::InvalidValue)?; - match atoi(self.buf) { - (v, len) if len > 0 && self.buf.get(len) == Some(&b'e') => { - self.buf = &self.buf[len + 1..]; - Ok(v) - } - _ => Err(Error::InvalidValue), - } - } - - fn parse_bytes(&mut self) -> Result<&'de [u8], Error> { - let b = match usize::from_radix_10(self.buf) { - (v, len) if len > 0 && self.buf.get(len) == Some(&b':') => { - self.buf = unsafe { self.buf.get_unchecked(len + 1..) }; - let (bytes, rest) = self.buf.split_at_checked(v).ok_or(Error::Eof)?; - self.buf = rest; - bytes - } - _ => return Err(Error::InvalidValue), - }; - if self.parsing_key && self.field_context.try_push(ByteBuf(b)).is_err() { - self.field_context_did_not_fit = self.field_context_did_not_fit.saturating_add(1); - } - Ok(b) - } -} - -/// Deserialize a bencode value. If there are trailing bytes, will error. -pub fn from_bytes<'a, T>(buf: &'a [u8]) -> Result> -where - T: serde::de::Deserialize<'a>, -{ - let (v, rest) = from_bytes_with_rest(buf)?; - if !rest.is_empty() { - return Err(ErrorWithContext { - kind: Error::BytesRemaining(rest.len()), - ctx: Default::default(), - }); - } - Ok(v) -} - -/// Deserialize a bencode value at the start of the buffer, return it and the remaining bytes. -pub fn from_bytes_with_rest<'a, T>(buf: &'a [u8]) -> Result<(T, &'a [u8]), ErrorWithContext<'a>> -where - T: serde::de::Deserialize<'a>, -{ - let mut de = BencodeDeserializer::new_from_buf(buf); - let v = match T::deserialize(&mut de) { - Ok(v) => v, - Err(e) => { - return Err(ErrorWithContext { - kind: e, - ctx: de.field_context, - }); - } - }; - Ok((v, de.buf)) -} - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("not supported by bencode")] - NotSupported, - #[error("{0}")] - Custom(Box), // box to reduce size - #[error("expected 0 or 1 for boolean, got {0}")] - InvalidBool(u8), - #[error("deserialized successfully, but {0} bytes remaining")] - BytesRemaining(usize), - #[error("invalid length: {0}")] - InvalidLength(usize), - #[error("invalid value")] - InvalidValue, - #[error("WithRawValue: invalid value")] - RawDeInvalidValue, - #[error("invalid utf-8")] - InvalidUtf8, - #[error("eof")] - Eof, -} - -type ErrorContext<'de> = ArrayVec, 4>; - -#[derive(Debug)] -pub struct ErrorWithContext<'de> { - kind: Error, - ctx: ErrorContext<'de>, -} - -impl ErrorWithContext<'_> { - pub fn kind(&self) -> &Error { - &self.kind - } - - pub fn into_kind(self) -> Error { - self.kind - } - - pub fn into_anyhow(self) -> anyhow::Error { - anyhow::Error::new(self.kind).context(format!("{:?}", self.ctx)) - } -} - -impl std::fmt::Display for ErrorWithContext<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for (id, ctx_key) in self.ctx.iter().copied().enumerate() { - if id > 0 { - write!(f, " -> {ctx_key:?}")?; - } else { - write!(f, "{ctx_key:?}")?; - } - } - if !self.ctx.is_empty() { - write!(f, ": ")?; - } - write!(f, "{}", self.kind) - } -} - -impl std::error::Error for ErrorWithContext<'_> { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - Some(&self.kind) - } -} - -impl serde::de::Error for Error { - fn custom(msg: T) -> Self - where - T: std::fmt::Display, - { - Self::Custom(Box::new(msg.to_string())) - } -} - -impl<'de> serde::de::Deserializer<'de> for &mut BencodeDeserializer<'de> { - type Error = Error; - - fn deserialize_any(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - match self.buf.first().copied() { - Some(b'd') => self.deserialize_map(visitor), - Some(b'i') => self.deserialize_i64(visitor), - Some(b'l') => self.deserialize_seq(visitor), - Some(_) => self.deserialize_bytes(visitor), - None => Err(Error::Eof), - } - } - - fn deserialize_bool(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - let value = self.parse_integer::(u8::from_radix_10)?; - if value > 1 { - return Err(Error::InvalidBool(value)); - } - visitor.visit_bool(value == 1) - } - - fn deserialize_i8(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_i8(self.parse_integer(i8::from_radix_10_signed)?) - } - - fn deserialize_i16(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_i16(self.parse_integer(i16::from_radix_10_signed)?) - } - - fn deserialize_i32(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_i32(self.parse_integer(i32::from_radix_10_signed)?) - } - - fn deserialize_i64(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_i64(self.parse_integer(i64::from_radix_10_signed)?) - } - - fn deserialize_u8(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_u8(self.parse_integer(u8::from_radix_10)?) - } - - fn deserialize_u16(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_u16(self.parse_integer(u16::from_radix_10)?) - } - - fn deserialize_u32(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_u32(self.parse_integer(u32::from_radix_10)?) - } - - fn deserialize_u64(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_u64(self.parse_integer(u64::from_radix_10)?) - } - - fn deserialize_f32(self, _visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::NotSupported) - } - - fn deserialize_f64(self, _visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::NotSupported) - } - - fn deserialize_char(self, _visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::NotSupported) - } - - fn deserialize_str(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - let b = self.parse_bytes()?; - let s = std::str::from_utf8(b).map_err(|_| Error::InvalidUtf8)?; - visitor.visit_borrowed_str(s) - } - - fn deserialize_string(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - self.deserialize_str(visitor) - } - - fn deserialize_bytes(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - let b = self.parse_bytes()?; - visitor.visit_borrowed_bytes(b) - } - - fn deserialize_byte_buf(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - self.deserialize_bytes(visitor) - } - - fn deserialize_option(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_some(&mut *self) - } - - fn deserialize_unit(self, _visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::NotSupported) - } - - fn deserialize_unit_struct( - self, - _name: &'static str, - _visitor: V, - ) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::NotSupported) - } - - fn deserialize_newtype_struct( - self, - name: &'static str, - visitor: V, - ) -> Result - where - V: serde::de::Visitor<'de>, - { - if name == TAG { - return visitor.visit_seq(WithRawValueDeserializer { - de: self, - buf: None, - }); - } - Err(Error::NotSupported) - } - - fn deserialize_seq(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - self.parse_first_byte(b'l', Error::InvalidValue)?; - visitor.visit_seq(SeqAccess { de: self }) - } - - fn deserialize_tuple(self, _len: usize, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - self.deserialize_seq(visitor) - } - - fn deserialize_tuple_struct( - self, - _name: &'static str, - _len: usize, - visitor: V, - ) -> Result - where - V: serde::de::Visitor<'de>, - { - self.deserialize_seq(visitor) - } - - fn deserialize_map(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - self.parse_first_byte(b'd', Error::InvalidValue)?; - visitor.visit_map(MapAccess { de: self }) - } - - fn deserialize_struct( - self, - _name: &'static str, - _fields: &'static [&'static str], - visitor: V, - ) -> Result - where - V: serde::de::Visitor<'de>, - { - self.deserialize_map(visitor) - } - - fn deserialize_enum( - self, - _name: &'static str, - _variants: &'static [&'static str], - _visitor: V, - ) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::NotSupported) - } - - fn deserialize_identifier(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_borrowed_bytes(self.parse_bytes()?) - } - - fn deserialize_ignored_any(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - self.deserialize_any(visitor) - } -} - -struct MapAccess<'a, 'de> { - de: &'a mut BencodeDeserializer<'de>, -} - -struct SeqAccess<'a, 'de> { - de: &'a mut BencodeDeserializer<'de>, -} - -impl<'de> serde::de::MapAccess<'de> for MapAccess<'_, 'de> { - type Error = Error; - - fn next_key_seed(&mut self, seed: K) -> Result, Self::Error> - where - K: serde::de::DeserializeSeed<'de>, - { - if self.de.buf.starts_with(b"e") { - self.de.buf = &self.de.buf[1..]; - return Ok(None); - } - self.de.parsing_key = true; - let retval = seed.deserialize(&mut *self.de)?; - self.de.parsing_key = false; - Ok(Some(retval)) - } - - fn next_value_seed(&mut self, seed: V) -> Result - where - V: serde::de::DeserializeSeed<'de>, - { - let value = seed.deserialize(&mut *self.de)?; - if self.de.field_context_did_not_fit > 0 { - self.de.field_context_did_not_fit -= 1; - } else { - self.de.field_context.pop(); - } - Ok(value) - } -} - -impl<'de> serde::de::SeqAccess<'de> for SeqAccess<'_, 'de> { - type Error = Error; - - fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> - where - T: serde::de::DeserializeSeed<'de>, - { - if self.de.buf.starts_with(b"e") { - self.de.buf = &self.de.buf[1..]; - return Ok(None); - } - Ok(Some(seed.deserialize(&mut *self.de)?)) - } -} - -struct WithRawValueDeserializer<'a, 'de> { - de: &'a mut BencodeDeserializer<'de>, - buf: Option<&'de [u8]>, -} - -impl<'a, 'de> serde::de::SeqAccess<'de> for WithRawValueDeserializer<'a, 'de> { - type Error = Error; - - fn next_element_seed(&mut self, seed: T) -> Result, Self::Error> - where - T: serde::de::DeserializeSeed<'de>, - { - let buf = match self.buf { - None => { - let buf_before = self.de.buf; - let el = seed.deserialize(&mut *self.de)?; - let buf_after = self.de.buf; - let consumed = buf_before.len() - buf_after.len(); - self.buf = Some(&buf_before[..consumed]); - return Ok(Some(el)); - } - Some(buf) => buf, - }; - - struct RawValueDe<'a>(&'a [u8]); - - impl<'de> serde::de::Deserializer<'de> for RawValueDe<'de> { - type Error = Error; - - fn deserialize_any(self, _: V) -> Result - where - V: serde::de::Visitor<'de>, - { - Err(Error::RawDeInvalidValue) - } - - fn deserialize_bytes(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_borrowed_bytes(self.0) - } - - fn deserialize_byte_buf(self, visitor: V) -> Result - where - V: serde::de::Visitor<'de>, - { - visitor.visit_borrowed_bytes(self.0) - } - - forward_to_deserialize_any! { - bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string - option unit unit_struct newtype_struct seq tuple - tuple_struct map struct enum identifier ignored_any - } - } - - let buf = seed.deserialize(RawValueDe(buf))?; - Ok(Some(buf)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WithRawBytes { - pub data: T, - pub raw_bytes: Buf, -} - -impl CloneToOwned for WithRawBytes { - type Target = WithRawBytes; - - fn clone_to_owned(&self, within_buffer: Option<&bytes::Bytes>) -> Self::Target { - WithRawBytes { - data: self.data.clone_to_owned(within_buffer), - raw_bytes: self.raw_bytes.clone_to_owned(within_buffer), - } - } -} - -impl Serialize for WithRawBytes { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.data.serialize(serializer) - } -} - -impl<'de, T, Buf> Deserialize<'de> for WithRawBytes -where - T: Deserialize<'de>, - Buf: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct Visitor(PhantomData<(T, Buf)>); - impl<'de, T, Buf> serde::de::Visitor<'de> for Visitor - where - T: Deserialize<'de>, - Buf: Deserialize<'de>, - { - type Value = WithRawBytes; - - fn expecting(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - fmt.write_str("WithRawBytes only works with librtbit_bencode") - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: serde::de::SeqAccess<'de>, - { - let data: T = match seq.next_element()? { - Some(v) => v, - None => { - return Err(::custom( - "expecting T as first element", - )); - } - }; - let raw_bytes: Buf = match seq.next_element()? { - Some(b) => b, - None => { - return Err(::custom( - "expecting buf as second element", - )); - } - }; - Ok(WithRawBytes { data, raw_bytes }) - } - } - deserializer.deserialize_newtype_struct(TAG, Visitor(Default::default())) - } -} - -#[cfg(test)] -mod tests { - use buffers::ByteBuf; - use serde::Deserialize; - - use crate::{WithRawBytes, from_bytes}; - - #[test] - fn test_deserialize_error_context() { - #[derive(Deserialize, Debug)] - struct Child { - #[allow(unused)] - key: u64, - } - #[derive(Deserialize, Debug)] - struct Parent { - #[allow(unused)] - child: Child, - } - - let e = from_bytes::(&b"d5:childd3:key2:hiee"[..]).expect_err("expected error"); - assert_eq!(format!("{e}"), "\"child\" -> \"key\": invalid value"); - } - - #[test] - fn test_int() { - assert_eq!(from_bytes::(b"i42e").unwrap(), 42); - assert_eq!(from_bytes::(b"i42e").unwrap(), 42); - assert_eq!(from_bytes::(b"i42e").unwrap(), 42); - assert_eq!(from_bytes::(b"i42e").unwrap(), 42); - - assert_eq!(from_bytes::(b"i4294967295e").unwrap(), 4294967295); - - // negative - assert_eq!(from_bytes::(b"i-1e").unwrap(), -1); - assert_eq!(from_bytes::(b"i-1e").unwrap(), -1); - - assert!(from_bytes::(b"ie").is_err()); - assert!(from_bytes::(b"ifooe").is_err()); - assert!(from_bytes::(b"i42").is_err()); - - // trailing bytes - assert!(from_bytes::(b"i42ee").is_err()); - } - - #[test] - fn test_str() { - assert_eq!( - from_bytes::>(b"5:hello").unwrap(), - ByteBuf(b"hello") - ); - assert_eq!(from_bytes::>(b"0:").unwrap(), ByteBuf(b"")); - - assert!(from_bytes::>(b"5:hell").is_err()); - assert!(from_bytes::>(b"5:helloworld").is_err()); - } - - #[test] - fn test_struct() { - #[derive(Deserialize, Eq, PartialEq, Debug)] - struct S<'a> { - key: u32, - #[serde(borrow)] - value: Option>, - } - - assert_eq!( - from_bytes::>(b"d3:keyi42ee").unwrap(), - S { - key: 42, - value: None - } - ); - - assert_eq!( - from_bytes::>(b"d3:keyi42e5:value5:helloe").unwrap(), - S { - key: 42, - value: Some(ByteBuf(b"hello")) - } - ); - } - - #[test] - fn test_list() { - assert_eq!(from_bytes::>>(b"le").unwrap(), vec![]); - assert_eq!( - from_bytes::>>(b"l5:hello2:mee").unwrap(), - vec![ByteBuf(b"hello"), ByteBuf(b"me")] - ); - } - - #[test] - fn test_with_raw_bytes() { - #[derive(Deserialize, Debug)] - struct TorrentInfo<'a> { - #[serde(borrow)] - name: ByteBuf<'a>, - } - - #[derive(Deserialize, Debug)] - struct Torrent<'a> { - #[serde(borrow)] - info: WithRawBytes, ByteBuf<'a>>, - } - - let t: Torrent = from_bytes(&b"d4:infod4:name5:helloee"[..]).unwrap(); - assert_eq!(t.info.data.name, ByteBuf(b"hello")); - assert_eq!(t.info.raw_bytes, ByteBuf(b"d4:name5:helloe")); - } - - #[test] - fn test_dict() { - use std::collections::BTreeMap; - let mut m = BTreeMap::new(); - m.insert(ByteBuf(b"key"), ByteBuf(b"value")); - m.insert(ByteBuf(b"key2"), ByteBuf(b"value2")); - assert_eq!( - from_bytes::>(b"d3:key5:value4:key26:value2e").unwrap(), - m - ); - } - - #[test] - fn test_struct_unknown_field() { - #[derive(Deserialize, Eq, PartialEq, Debug)] - struct S { - key: u32, - } - - assert_eq!( - from_bytes::(b"d3:keyi42e5:value5:helloe").unwrap(), - S { key: 42 } - ); - } - - #[test] - fn test_complex_struct() { - #[derive(Deserialize, Eq, PartialEq, Debug)] - struct S<'a> { - key: u32, - #[serde(borrow)] - values: Vec>, - } - - assert_eq!( - from_bytes::(b"d3:keyi42e6:valuesl5:hello5:worldee").unwrap(), - S { - key: 42, - values: vec![ByteBuf(b"hello"), ByteBuf(b"world")] - } - ); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/lib.rs deleted file mode 100644 index 9cccb7fb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -mod bencode_value; -mod deserialize; -pub mod raw_value; -mod serialize; - -pub use bencode_value::*; -pub use deserialize::{ - BencodeDeserializer, Error as DeserializeError, - ErrorWithContext as DeserializeErrorWithContext, WithRawBytes, from_bytes, - from_bytes_with_rest, -}; -pub use serialize::{Error as SerializeError, bencode_serialize_to_writer}; - -pub use buffers::{ByteBuf, ByteBufOwned}; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/raw_value.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/raw_value.rs deleted file mode 100644 index 7f7c2427..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/raw_value.rs +++ /dev/null @@ -1,28 +0,0 @@ -use serde::Serialize; - -pub struct RawValue(pub T); - -pub(crate) const TAG: &str = "::librtbit_bencode::RawValue"; - -impl Serialize for RawValue -where - T: AsRef<[u8]>, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - struct Wrapper<'a>(&'a [u8]); - - impl Serialize for Wrapper<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_bytes(self.0) - } - } - - serializer.serialize_newtype_struct(TAG, &Wrapper(self.0.as_ref())) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/serialize.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/serialize.rs deleted file mode 100644 index 640a6ff7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/bencode/src/serialize.rs +++ /dev/null @@ -1,455 +0,0 @@ -use std::collections::BTreeMap; - -use serde::{Serialize, Serializer, ser::Impossible}; - -use buffers::ByteBufOwned; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error(transparent)] - Io(std::io::Error), - #[error("{0}")] - Custom(Box>), // double box to reduce size - #[error("{0}")] - Text(&'static &'static str), -} - -impl serde::ser::Error for Error { - fn custom(msg: T) -> Self - where - T: std::fmt::Display, - { - Error::Custom(Box::new(msg.to_string().into_boxed_str())) - } -} - -struct BencodeSerializer { - writer: W, - hack_no_bytestring_prefix: bool, -} - -impl BencodeSerializer { - pub fn new(writer: W) -> Self { - Self { - writer, - hack_no_bytestring_prefix: false, - } - } - fn write_raw(&mut self, buf: &[u8]) -> Result<(), Error> { - self.writer.write_all(buf).map_err(Error::Io) - } - fn write_fmt(&mut self, fmt: core::fmt::Arguments<'_>) -> Result<(), Error> { - self.writer.write_fmt(fmt).map_err(Error::Io) - } - fn write_byte(&mut self, byte: u8) -> Result<(), Error> { - self.write_raw(&[byte]) - } - fn write_number(&mut self, number: N) -> Result<(), Error> { - self.write_fmt(format_args!("i{number}e")) - } - fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Error> { - if !self.hack_no_bytestring_prefix { - self.write_fmt(format_args!("{}:", bytes.len()))?; - } - self.write_raw(bytes) - } -} - -struct SerializeSeq<'ser, W: std::io::Write> { - ser: &'ser mut BencodeSerializer, -} -impl serde::ser::SerializeSeq for SerializeSeq<'_, W> { - type Ok = (); - - type Error = Error; - - fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> - where - T: ?Sized + serde::Serialize, - { - value.serialize(&mut *self.ser) - } - - fn end(self) -> Result { - self.ser.write_byte(b'e') - } -} - -struct SerializeTuple<'ser, W: std::io::Write> { - ser: &'ser mut BencodeSerializer, -} -impl serde::ser::SerializeTuple for SerializeTuple<'_, W> { - type Ok = (); - - type Error = Error; - - fn serialize_element(&mut self, value: &T) -> Result<(), Self::Error> - where - T: ?Sized + serde::Serialize, - { - value.serialize(&mut *self.ser) - } - - fn end(self) -> Result { - self.ser.write_byte(b'e') - } -} - -struct SerializeMap<'ser, W: std::io::Write> { - ser: &'ser mut BencodeSerializer, - tmp: BTreeMap, - last_key: Option, -} -impl serde::ser::SerializeMap for SerializeMap<'_, W> { - type Ok = (); - - type Error = Error; - - fn serialize_key(&mut self, key: &T) -> Result<(), Self::Error> - where - T: ?Sized + serde::Serialize, - { - let mut buf = Vec::new(); - let mut ser = BencodeSerializer::new(&mut buf); - ser.hack_no_bytestring_prefix = true; - key.serialize(&mut ser)?; - self.last_key.replace(ByteBufOwned::from(buf)); - Ok(()) - } - - fn serialize_value(&mut self, value: &T) -> Result<(), Self::Error> - where - T: ?Sized + serde::Serialize, - { - let mut buf = Vec::new(); - let mut ser = BencodeSerializer::new(&mut buf); - value.serialize(&mut ser)?; - self.tmp - .insert(self.last_key.take().unwrap(), ByteBufOwned::from(buf)); - Ok(()) - } - - fn end(self) -> Result { - for (key, value) in self.tmp { - self.ser.write_bytes(key.as_ref())?; - self.ser.write_raw(value.as_ref())?; - } - self.ser.write_byte(b'e') - } -} - -struct SerializeStruct<'ser, W: std::io::Write> { - ser: &'ser mut BencodeSerializer, - tmp: BTreeMap<&'static str, ByteBufOwned>, -} -impl serde::ser::SerializeStruct for SerializeStruct<'_, W> { - type Ok = (); - - type Error = Error; - - fn serialize_field(&mut self, key: &'static str, value: &T) -> Result<(), Self::Error> - where - T: ?Sized + serde::Serialize, - { - let mut buf = Vec::new(); - let mut ser = BencodeSerializer::new(&mut buf); - value.serialize(&mut ser)?; - self.tmp.insert(key, ByteBufOwned::from(buf)); - Ok(()) - } - - fn end(self) -> Result { - for (key, value) in self.tmp { - self.ser.write_bytes(key.as_bytes())?; - self.ser.write_raw(value.as_ref())?; - } - self.ser.write_byte(b'e') - } -} - -impl<'ser, W: std::io::Write> Serializer for &'ser mut BencodeSerializer { - type Ok = (); - type Error = Error; - type SerializeSeq = SerializeSeq<'ser, W>; - type SerializeTuple = SerializeTuple<'ser, W>; - type SerializeTupleStruct = Impossible<(), Error>; - type SerializeTupleVariant = Impossible<(), Error>; - type SerializeMap = SerializeMap<'ser, W>; - type SerializeStruct = SerializeStruct<'ser, W>; - type SerializeStructVariant = Impossible<(), Error>; - - fn serialize_bool(self, value: bool) -> Result { - self.write_number(if value { 1 } else { 0 }) - } - - fn serialize_i8(self, v: i8) -> Result { - self.write_number(v) - } - - fn serialize_i16(self, v: i16) -> Result { - self.write_number(v) - } - - fn serialize_i32(self, v: i32) -> Result { - self.write_number(v) - } - - fn serialize_i64(self, v: i64) -> Result { - self.write_number(v) - } - - fn serialize_u8(self, v: u8) -> Result { - self.write_number(v) - } - - fn serialize_u16(self, v: u16) -> Result { - self.write_number(v) - } - - fn serialize_u32(self, v: u32) -> Result { - self.write_number(v) - } - - fn serialize_u64(self, v: u64) -> Result { - self.write_number(v) - } - - fn serialize_f32(self, _: f32) -> Result { - Err(Error::Text(&"bencode doesn't support f32")) - } - - fn serialize_f64(self, _: f64) -> Result { - Err(Error::Text(&"bencode doesn't support f32")) - } - - fn serialize_char(self, _: char) -> Result { - Err(Error::Text(&"bencode doesn't support chars")) - } - - fn serialize_str(self, v: &str) -> Result { - self.write_bytes(v.as_bytes()) - } - - fn serialize_bytes(self, v: &[u8]) -> Result { - self.write_bytes(v) - } - - fn serialize_none(self) -> Result { - Err(Error::Text(&"bencode doesn't support None")) - } - - fn serialize_some(self, value: &T) -> Result - where - T: ?Sized + serde::Serialize, - { - value.serialize(self) - } - - fn serialize_unit(self) -> Result { - Err(Error::Text(&"bencode doesn't support Rust unit ()")) - } - - fn serialize_unit_struct(self, _name: &'static str) -> Result { - Err(Error::Text(&"bencode doesn't support unit structs")) - } - - fn serialize_unit_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - ) -> Result { - Err(Error::Text(&"bencode doesn't support unit variants")) - } - - fn serialize_newtype_struct( - self, - name: &'static str, - value: &T, - ) -> Result - where - T: ?Sized + serde::Serialize, - { - if name == crate::raw_value::TAG { - self.hack_no_bytestring_prefix = true; - value.serialize(&mut *self)?; - self.hack_no_bytestring_prefix = false; - return Ok(()); - } - Err(Error::Text(&"bencode doesn't support newtype structs")) - } - - fn serialize_newtype_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _value: &T, - ) -> Result - where - T: ?Sized + serde::Serialize, - { - Err(Error::Text(&"bencode doesn't support newtype variants")) - } - - fn serialize_seq(self, _len: Option) -> Result { - self.write_byte(b'l')?; - Ok(SerializeSeq { ser: self }) - } - - fn serialize_tuple(self, _len: usize) -> Result { - Err(Error::Text(&"bencode doesn't support tuples")) - } - - fn serialize_tuple_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result { - Err(Error::Text(&"bencode doesn't support tuple structs")) - } - - fn serialize_tuple_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result { - Err(Error::Text(&"bencode doesn't support tuple variants")) - } - - fn serialize_map(self, _len: Option) -> Result { - self.write_byte(b'd')?; - Ok(SerializeMap { - ser: self, - tmp: Default::default(), - last_key: None, - }) - } - - fn serialize_struct( - self, - _name: &'static str, - _len: usize, - ) -> Result { - self.write_byte(b'd')?; - Ok(SerializeStruct { - ser: self, - tmp: Default::default(), - }) - } - - fn serialize_struct_variant( - self, - _name: &'static str, - _variant_index: u32, - _variant: &'static str, - _len: usize, - ) -> Result { - Err(Error::Text(&"bencode doesn't support struct variants")) - } -} - -pub fn bencode_serialize_to_writer( - value: T, - writer: &mut W, -) -> Result<(), Error> { - let mut serializer = BencodeSerializer::new(writer); - value.serialize(&mut serializer)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use buffers::{ByteBuf, ByteBufOwned}; - use serde::Serialize; - - use crate::bencode_serialize_to_writer; - - fn ser(value: T) -> Result { - let mut vec = Vec::new(); - bencode_serialize_to_writer(&value, &mut vec)?; - Ok(vec.into()) - } - - #[test] - fn test_ints() { - assert_eq!(ser(42u16).unwrap(), b"i42e"[..].into()); - assert_eq!(ser(42u32).unwrap(), b"i42e"[..].into()); - assert_eq!(ser(42u64).unwrap(), b"i42e"[..].into()); - } - - #[test] - fn test_bytes() { - assert_eq!(ser(ByteBuf(b"abc")).unwrap(), b"3:abc"[..].into()); - assert_eq!( - ser(ByteBufOwned::from(&b"abc"[..])).unwrap(), - b"3:abc"[..].into() - ); - } - - #[test] - fn test_seq() { - assert_eq!( - ser(&[ByteBuf(b"foo"), ByteBuf(b"bar")][..]).unwrap(), - b"l3:foo3:bare"[..].into() - ); - assert_eq!( - ser(vec![ByteBuf(b"foo"), ByteBuf(b"bar")]).unwrap(), - b"l3:foo3:bare"[..].into() - ); - } - - #[test] - fn test_struct() { - #[derive(Serialize, Debug)] - struct S<'a> { - key: u32, - value: ByteBuf<'a>, - } - assert_eq!( - ser(S { - key: 42, - value: b"foo"[..].into() - }) - .unwrap(), - b"d3:keyi42e5:value3:fooe"[..].into() - ); - } - - #[test] - fn test_dict() { - use std::collections::BTreeMap; - let mut m = BTreeMap::new(); - m.insert(ByteBuf(b"key"), ByteBuf(b"value")); - m.insert(ByteBuf(b"key2"), ByteBuf(b"value2")); - assert_eq!(ser(&m).unwrap(), b"d3:key5:value4:key26:value2e"[..].into()); - } - - #[test] - fn test_struct_with_option() { - #[derive(Serialize, Debug)] - struct S<'a> { - key: u32, - #[serde(skip_serializing_if = "Option::is_none")] - value: Option>, - } - assert_eq!( - ser(S { - key: 42, - value: Some(b"foo"[..].into()) - }) - .unwrap(), - b"d3:keyi42e5:value3:fooe"[..].into() - ); - assert_eq!( - ser(S { - key: 42, - value: None - }) - .unwrap(), - b"d3:keyi42ee"[..].into() - ); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/Cargo.toml deleted file mode 100644 index d7e781fd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "librtbit-buffers" -version = "4.2.0" -edition = "2024" -description = "Utils to work with &[u8] and Vec in librtbit source code." -license = "Apache-2.0" -documentation = "https://docs.rs/librtbit-buffers" -readme = "README.md" - -[lints] -workspace = true - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -serde.workspace = true -serde_derive.workspace = true -clone_to_owned.workspace = true -bytes.workspace = true diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/README.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/README.md deleted file mode 120000 index 32d46ee8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/src/lib.rs deleted file mode 100644 index 28c81270..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/buffers/src/lib.rs +++ /dev/null @@ -1,379 +0,0 @@ -// This crate used for making working with &[u8] or Vec generic in other parts of librtbit, -// for nicer display of binary data etc. -// -// Not useful outside of librtbit. - -use std::borrow::Borrow; - -use bytes::Bytes; -use serde::{Deserializer, Serialize}; -use serde_derive::Deserialize; - -use clone_to_owned::CloneToOwned; - -#[derive(Default, PartialEq, Eq, Hash, Clone, PartialOrd, Ord)] -pub struct ByteBufOwned(pub bytes::Bytes); - -#[derive(Default, Deserialize, PartialEq, Eq, Hash, Clone, Copy, PartialOrd, Ord)] -#[serde(transparent)] -pub struct ByteBuf<'a>(pub &'a [u8]); - -pub trait ByteBufT: - AsRef<[u8]> - + Default - + std::hash::Hash - + Serialize - + Eq - + core::fmt::Debug - + CloneToOwned - + Borrow<[u8]> -{ -} - -impl ByteBufT for ByteBufOwned {} - -impl ByteBufT for ByteBuf<'_> {} - -struct HexBytes<'a>(&'a [u8]); -impl std::fmt::Display for HexBytes<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for byte in self.0 { - write!(f, "{byte:02x?}")?; - } - Ok(()) - } -} - -fn debug_bytes(b: &[u8], f: &mut std::fmt::Formatter<'_>, debug_strings: bool) -> std::fmt::Result { - if b.is_empty() { - return Ok(()); - } - if b.iter().all(|b| *b == 0) { - return write!(f, "<{} bytes, all zeroes>", b.len()); - } - match std::str::from_utf8(b) { - Ok(s) => { - if debug_strings { - return write!(f, "{s:?}"); - } else { - return write!(f, "{s}"); - } - } - Err(_e) => {} - }; - - // up to 20 bytes, display hex - if b.len() <= 20 { - return write!(f, "<{} bytes, 0x{}>", b.len(), HexBytes(b)); - } - - write!(f, "<{} bytes>", b.len()) -} - -impl std::fmt::Debug for ByteBuf<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - debug_bytes(self.0, f, true) - } -} - -impl std::fmt::Display for ByteBuf<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - debug_bytes(self.0, f, false) - } -} - -impl std::fmt::Debug for ByteBufOwned { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - debug_bytes(&self.0, f, true) - } -} - -impl std::fmt::Display for ByteBufOwned { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - debug_bytes(&self.0, f, false) - } -} - -impl CloneToOwned for ByteBuf<'_> { - type Target = ByteBufOwned; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - // Try zero-copy from the provided buffer. - if let Some(within_buffer) = within_buffer { - let haystack = within_buffer.as_ptr() as usize; - let haystack_end = haystack + within_buffer.len(); - let needle = self.0.as_ptr() as usize; - let needle_end = needle + self.0.len(); - - if needle >= haystack && needle_end <= haystack_end { - return ByteBufOwned(within_buffer.slice_ref(self.0.as_ref())); - } else { - #[cfg(debug_assertions)] - panic!("bug: broken buffers! not inside within_buffer"); - } - } - - ByteBufOwned(Bytes::copy_from_slice(self.0)) - } -} - -impl CloneToOwned for ByteBufOwned { - type Target = ByteBufOwned; - - fn clone_to_owned(&self, _within_buffer: Option<&Bytes>) -> Self::Target { - ByteBufOwned(self.0.clone()) - } -} - -impl std::convert::AsRef<[u8]> for ByteBuf<'_> { - fn as_ref(&self) -> &[u8] { - self.0 - } -} - -impl std::convert::AsRef<[u8]> for ByteBufOwned { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -impl std::borrow::Borrow<[u8]> for ByteBufOwned { - fn borrow(&self) -> &[u8] { - &self.0 - } -} - -impl std::borrow::Borrow<[u8]> for ByteBuf<'_> { - fn borrow(&self) -> &[u8] { - self.0 - } -} - -impl<'a> From<&'a [u8]> for ByteBuf<'a> { - fn from(b: &'a [u8]) -> Self { - Self(b) - } -} - -impl<'a> From<&'a [u8]> for ByteBufOwned { - fn from(b: &'a [u8]) -> Self { - Self(b.to_owned().into()) - } -} - -impl From> for ByteBufOwned { - fn from(b: Vec) -> Self { - Self(b.into()) - } -} - -impl From for ByteBufOwned { - fn from(b: Bytes) -> Self { - Self(b) - } -} - -impl serde::ser::Serialize for ByteBuf<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_bytes(self.as_ref()) - } -} - -impl serde::ser::Serialize for ByteBufOwned { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_bytes(self.as_ref()) - } -} - -impl<'de> serde::de::Deserialize<'de> for ByteBufOwned { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct Visitor; - - impl serde::de::Visitor<'_> for Visitor { - type Value = ByteBufOwned; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("byte string") - } - fn visit_bytes(self, v: &[u8]) -> Result - where - E: serde::de::Error, - { - Ok(v.to_owned().into()) - } - } - deserializer.deserialize_byte_buf(Visitor {}) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_byte_buf_owned_from_vec() { - let v = vec![1u8, 2, 3, 4, 5]; - let buf = ByteBufOwned::from(v.clone()); - assert_eq!(buf.as_ref(), &v[..]); - } - - #[test] - fn test_byte_buf_owned_as_ref() { - let data = vec![10u8, 20, 30]; - let buf = ByteBufOwned::from(data.clone()); - let slice: &[u8] = buf.as_ref(); - assert_eq!(slice, &[10, 20, 30]); - } - - #[test] - fn test_byte_buf_owned_clone() { - let buf = ByteBufOwned::from(vec![0xDE, 0xAD, 0xBE, 0xEF]); - let cloned = buf.clone(); - assert_eq!(buf.as_ref(), cloned.as_ref()); - } - - #[test] - fn test_byte_buf_owned_empty() { - let buf = ByteBufOwned::default(); - assert_eq!(buf.as_ref(), &[] as &[u8]); - assert!(buf.as_ref().is_empty()); - } - - #[test] - fn test_byte_buf_owned_from_slice() { - let data: &[u8] = &[42, 43, 44]; - let buf = ByteBufOwned::from(data); - assert_eq!(buf.as_ref(), data); - } - - #[test] - fn test_byte_buf_owned_from_bytes() { - let b = Bytes::from_static(&[1, 2, 3]); - let buf = ByteBufOwned::from(b.clone()); - assert_eq!(buf.as_ref(), b.as_ref()); - } - - #[test] - fn test_byte_buf_owned_partial_eq() { - let a = ByteBufOwned::from(vec![1, 2, 3]); - let b = ByteBufOwned::from(vec![1, 2, 3]); - let c = ByteBufOwned::from(vec![4, 5, 6]); - assert_eq!(a, b); - assert_ne!(a, c); - } - - #[test] - fn test_byte_buf_owned_borrow() { - let buf = ByteBufOwned::from(vec![7, 8, 9]); - let borrowed: &[u8] = std::borrow::Borrow::borrow(&buf); - assert_eq!(borrowed, &[7, 8, 9]); - } - - #[test] - fn test_byte_buf_owned_debug_utf8() { - // When bytes are valid UTF-8, debug should show the string - let buf = ByteBufOwned::from(b"hello".to_vec()); - let debug = format!("{:?}", buf); - assert!(debug.contains("hello"), "got: {}", debug); - } - - #[test] - fn test_byte_buf_owned_debug_hex() { - // When bytes are not valid UTF-8 and <= 20 bytes, debug should show hex - let buf = ByteBufOwned::from(vec![0xFF, 0xFE, 0xFD]); - let debug = format!("{:?}", buf); - assert!(debug.contains("bytes"), "got: {}", debug); - assert!(debug.contains("fffefd"), "got: {}", debug); - } - - #[test] - fn test_byte_buf_owned_debug_all_zeroes() { - let buf = ByteBufOwned::from(vec![0u8; 10]); - let debug = format!("{:?}", buf); - assert!( - debug.contains("all zeroes"), - "expected 'all zeroes' in debug output, got: {}", - debug - ); - } - - #[test] - fn test_byte_buf_owned_display_utf8() { - // Display should show the string without quotes - let buf = ByteBufOwned::from(b"world".to_vec()); - let display = format!("{}", buf); - assert_eq!(display, "world"); - } - - #[test] - fn test_byte_buf_borrowed_from_slice() { - let data: &[u8] = &[1, 2, 3]; - let buf = ByteBuf::from(data); - assert_eq!(buf.as_ref(), data); - } - - #[test] - fn test_byte_buf_borrowed_default() { - let buf = ByteBuf::default(); - assert!(buf.as_ref().is_empty()); - } - - #[test] - fn test_byte_buf_borrowed_partial_eq() { - let a = ByteBuf(&[1, 2]); - let b = ByteBuf(&[1, 2]); - let c = ByteBuf(&[3, 4]); - assert_eq!(a, b); - assert_ne!(a, c); - } - - #[test] - fn test_byte_buf_borrowed_borrow() { - let buf = ByteBuf(&[10, 20]); - let borrowed: &[u8] = std::borrow::Borrow::borrow(&buf); - assert_eq!(borrowed, &[10, 20]); - } - - #[test] - fn test_byte_buf_owned_ord() { - let a = ByteBufOwned::from(vec![1, 2, 3]); - let b = ByteBufOwned::from(vec![1, 2, 4]); - let c = ByteBufOwned::from(vec![1, 2, 3]); - assert!(a < b); - assert_eq!(a.cmp(&c), std::cmp::Ordering::Equal); - } - - #[test] - fn test_byte_buf_owned_hash() { - use std::collections::HashSet; - let mut set = HashSet::new(); - set.insert(ByteBufOwned::from(vec![1, 2, 3])); - set.insert(ByteBufOwned::from(vec![1, 2, 3])); // duplicate - set.insert(ByteBufOwned::from(vec![4, 5, 6])); - assert_eq!(set.len(), 2); - } - - #[test] - fn test_clone_to_owned_byte_buf_without_parent() { - let data: &[u8] = &[100, 200]; - let buf = ByteBuf(data); - let owned = buf.clone_to_owned(None); - assert_eq!(owned.as_ref(), data); - } - - #[test] - fn test_clone_to_owned_byte_buf_owned() { - let buf = ByteBufOwned::from(vec![5, 6, 7]); - let cloned = buf.clone_to_owned(None); - assert_eq!(cloned.as_ref(), buf.as_ref()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/Cargo.toml deleted file mode 100644 index 019efe83..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "librtbit-clone-to-owned" -version = "3.0.1" -edition = "2024" -description = "Util traits to represent something that can be made owned and change type at the same time." -license = "Apache-2.0" -documentation = "https://docs.rs/librtbit-clone-to-owned" -readme = "README.md" - -[lints] -workspace = true - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -bytes.workspace = true diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/README.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/README.md deleted file mode 120000 index 32d46ee8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/src/lib.rs deleted file mode 100644 index d027b9c7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/clone_to_owned/src/lib.rs +++ /dev/null @@ -1,96 +0,0 @@ -// These are helpers for objects that can be borrowed, but can be made owned while changing the type. -// The difference between e.g. Cow and CloneToOwned, is that we can implement it recursively for owned types. -// -// E.g. HashMap<&str, &str> can be converted to HashMap. -// -// This lets us express types like TorrentMetaInfo<&[u8]> for zero-copy metadata about a bencode buffer in memory, -// but to have one-line conversion for it into TorrentMetaInfo> so that we can store it later somewhere. - -use bytes::Bytes; -use std::collections::{BTreeMap, HashMap}; - -pub trait CloneToOwned { - type Target; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target; -} - -impl CloneToOwned for Option -where - T: CloneToOwned, -{ - type Target = Option<::Target>; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - self.as_ref().map(|i| i.clone_to_owned(within_buffer)) - } -} - -impl CloneToOwned for Vec -where - T: CloneToOwned, -{ - type Target = Vec<::Target>; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - self.iter() - .map(|i| i.clone_to_owned(within_buffer)) - .collect() - } -} - -impl CloneToOwned for u8 { - type Target = u8; - - fn clone_to_owned(&self, _within_buffer: Option<&Bytes>) -> Self::Target { - *self - } -} - -impl CloneToOwned for u32 { - type Target = u32; - - fn clone_to_owned(&self, _within_buffer: Option<&Bytes>) -> Self::Target { - *self - } -} - -impl CloneToOwned for HashMap -where - K: CloneToOwned, - ::Target: std::hash::Hash + Eq, - V: CloneToOwned, -{ - type Target = HashMap<::Target, ::Target>; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - let mut result = HashMap::with_capacity(self.capacity()); - for (k, v) in self { - result.insert( - k.clone_to_owned(within_buffer), - v.clone_to_owned(within_buffer), - ); - } - result - } -} - -impl CloneToOwned for BTreeMap -where - K: CloneToOwned, - ::Target: Ord, - V: CloneToOwned, -{ - type Target = BTreeMap<::Target, ::Target>; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - self.iter() - .map(|(k, v)| { - ( - k.clone_to_owned(within_buffer), - v.clone_to_owned(within_buffer), - ) - }) - .collect() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/Cargo.toml deleted file mode 100644 index 0e759be1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/Cargo.toml +++ /dev/null @@ -1,47 +0,0 @@ -[package] -name = "librtbit-dht" -version = "5.3.0" -edition = "2024" -description = "DHT implementation, used in rtbit torrent client." -license = "Apache-2.0" -documentation = "https://docs.rs/librtbit-dht" -repository = "https://github.com/ikatson/rtbit" -readme = "README.md" - -[lints] -workspace = true - -[features] -default = ["sha1-crypto-hash"] -sha1-crypto-hash = ["sha1w/sha1-crypto-hash"] -sha1-ring = ["sha1w/sha1-ring"] - -[dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "sync"] } -tokio-stream = { workspace = true, features = ["sync"] } -serde.workspace = true -serde_derive.workspace = true -leaky-bucket.workspace = true -serde_json.workspace = true -buffers.workspace = true -bencode.workspace = true -anyhow.workspace = true -parking_lot.workspace = true -tracing.workspace = true -backon = { workspace = true, features = ["tokio-sleep"] } -futures.workspace = true -rand.workspace = true -indexmap.workspace = true -dashmap = { workspace = true, features = ["serde"] } -clone_to_owned.workspace = true -librtbit-core.workspace = true -chrono = { workspace = true, features = ["serde"] } -tokio-util.workspace = true -bytes.workspace = true -librqbit-dualstack-sockets.workspace = true -thiserror.workspace = true -sha1w.workspace = true -aws-lc-rs.workspace = true - -[dev-dependencies] -tracing-subscriber.workspace = true diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/README.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/README.md deleted file mode 120000 index 32d46ee8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/README.md +++ /dev/null @@ -1 +0,0 @@ -../README.md \ No newline at end of file diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/examples/dht.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/examples/dht.rs deleted file mode 100644 index df20e692..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/examples/dht.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::time::Duration; - -use anyhow::Context; -use librtbit_core::magnet::Magnet; -use librtbit_dht::DhtBuilder; -use tokio_stream::StreamExt; -use tracing::info; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let magnet = std::env::args() - .nth(1) - .expect("first argument should be a magnet link"); - let magnet = Magnet::parse(&magnet).unwrap(); - let info_hash = magnet - .as_id20() - .context("Supplied magnet link didn't contain a BTv1 infohash")?; - - tracing_subscriber::fmt::init(); - - let dht = DhtBuilder::new().await.context("error initializing DHT")?; - - let mut stream = dht.get_peers(info_hash, None); - - let stats_printer = async { - loop { - tokio::time::sleep(Duration::from_secs(5)).await; - info!("DHT stats: {:?}", dht.stats()); - } - #[allow(unreachable_code)] - Ok::<_, anyhow::Error>(()) - }; - - let routing_table_dumper = async { - loop { - tokio::time::sleep(Duration::from_secs(15)).await; - dht.with_routing_tables(|v4, v6| { - let filename = "/tmp/routing-table.json"; - let mut f = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(filename) - .unwrap(); - serde_json::to_writer_pretty(&mut f, &[v4, v6]).unwrap(); - info!("Dumped DHT routing table to {}", filename); - }); - } - #[allow(unreachable_code)] - Ok::<_, anyhow::Error>(()) - }; - - let peer_printer = async { - while let Some(peer) = stream.next().await { - info!("peer found: {}", peer) - } - Ok(()) - }; - - let res = tokio::select! { - res = stats_printer => res, - res = peer_printer => res, - res = routing_table_dumper => res, - }; - res -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_request.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_request.bin deleted file mode 100644 index 610df7bb..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_request.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_1.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_1.bin deleted file mode 100644 index 62dfcd80..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_1.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_2.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_2.bin deleted file mode 100644 index 414bdd3d..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_2.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_3.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_3.bin deleted file mode 100644 index 0d1c7b6d..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/find_node_response_3.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/get_peers_request_0.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/get_peers_request_0.bin deleted file mode 100644 index 5518dbc8..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/get_peers_request_0.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/get_peers_request_1.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/get_peers_request_1.bin deleted file mode 100644 index fb2a0241..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/resources/test_requests/get_peers_request_1.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/bep44_crypto.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/bep44_crypto.rs deleted file mode 100644 index 8a588304..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/bep44_crypto.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! BEP 44 cryptographic utilities for mutable DHT items. -//! -//! Provides Ed25519 signing/verification and target computation for BEP 44 -//! (Storing arbitrary data in the DHT) mutable items. - -use librtbit_core::hash_id::Id20; -use sha1w::ISha1; - -/// Compute the DHT target for a BEP 44 mutable item. -/// -/// For mutable items, the target is SHA-1 of the public key concatenated -/// with the optional salt: -/// - `target = SHA-1(public_key)` if no salt -/// - `target = SHA-1(public_key + salt)` if salt is provided -pub fn mutable_item_target(public_key: &[u8; 32], salt: Option<&[u8]>) -> Id20 { - let mut hasher = sha1w::Sha1::new(); - hasher.update(public_key); - if let Some(s) = salt { - hasher.update(s); - } - Id20::new(hasher.finish()) -} - -/// Construct the signable data for a BEP 44 mutable item. -/// -/// The data to be signed is constructed per BEP 44: -/// ```text -/// [4:salt{len}:{salt}]3:seqi{seq}e1:v{bencoded_v} -/// ``` -/// -/// The salt prefix is only included when salt is present. -/// `v` should be the bencoded representation of the value. -pub fn signable_data(salt: Option<&[u8]>, seq: i64, v: &[u8]) -> Vec { - let mut buf = Vec::new(); - - // Optional salt prefix - if let Some(s) = salt { - buf.extend_from_slice(format!("4:salt{}:", s.len()).as_bytes()); - buf.extend_from_slice(s); - } - - // Sequence number - buf.extend_from_slice(format!("3:seqi{seq}e").as_bytes()); - - // Value - buf.extend_from_slice(b"1:v"); - buf.extend_from_slice(v); - - buf -} - -/// Verify a BEP 44 mutable item Ed25519 signature. -/// -/// Returns `true` if the signature is valid for the given parameters. -pub fn verify_mutable_item( - public_key: &[u8; 32], - signature: &[u8; 64], - salt: Option<&[u8]>, - seq: i64, - v: &[u8], -) -> bool { - use aws_lc_rs::signature; - - let msg = signable_data(salt, seq, v); - - let peer_public_key = signature::UnparsedPublicKey::new(&signature::ED25519, public_key); - peer_public_key.verify(&msg, signature).is_ok() -} - -/// Sign a BEP 44 mutable item with an Ed25519 private key seed. -/// -/// `private_key_seed` is the 32-byte Ed25519 seed (private key material). -/// Returns the 64-byte Ed25519 signature. -/// -/// # Panics -/// -/// Panics if the seed is invalid (should not happen with a valid 32-byte seed). -pub fn sign_mutable_item( - private_key_seed: &[u8; 32], - salt: Option<&[u8]>, - seq: i64, - v: &[u8], -) -> [u8; 64] { - use aws_lc_rs::signature; - - let msg = signable_data(salt, seq, v); - - // Ed25519 key pair from the 32-byte seed. - let key_pair = signature::Ed25519KeyPair::from_seed_unchecked(private_key_seed) - .expect("valid Ed25519 seed"); - let sig = key_pair.sign(&msg); - let sig_bytes = sig.as_ref(); - - let mut result = [0u8; 64]; - result.copy_from_slice(sig_bytes); - result -} - -/// Extract the 32-byte Ed25519 public key from a 32-byte seed. -/// -/// Useful for deriving the public key to compute the DHT target. -pub fn public_key_from_seed(seed: &[u8; 32]) -> [u8; 32] { - use aws_lc_rs::signature::{self, KeyPair}; - - let key_pair = - signature::Ed25519KeyPair::from_seed_unchecked(seed).expect("valid Ed25519 seed"); - let pub_key = key_pair.public_key().as_ref(); - let mut result = [0u8; 32]; - result.copy_from_slice(pub_key); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_signable_data_no_salt() { - let v = b"12:Hello World!"; - let data = signable_data(None, 1, v); - assert_eq!(&data, b"3:seqi1e1:v12:Hello World!"); - } - - #[test] - fn test_signable_data_with_salt() { - let v = b"12:Hello World!"; - let salt = b"foobar"; - let data = signable_data(Some(salt), 4, v); - assert_eq!(&data, b"4:salt6:foobar3:seqi4e1:v12:Hello World!"); - } - - #[test] - fn test_signable_data_negative_seq() { - let v = b"i42e"; - let data = signable_data(None, -1, v); - assert_eq!(&data, b"3:seqi-1e1:vi42e"); - } - - #[test] - fn test_signable_data_zero_seq() { - let v = b"i0e"; - let data = signable_data(None, 0, v); - assert_eq!(&data, b"3:seqi0e1:vi0e"); - } - - #[test] - fn test_signable_data_empty_salt() { - let v = b"i1e"; - // Empty salt is still included per BEP 44 spec - let data = signable_data(Some(b""), 1, v); - assert_eq!(&data, b"4:salt0:3:seqi1e1:vi1e"); - } - - #[test] - fn test_sign_and_verify_roundtrip() { - let seed: [u8; 32] = [ - 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, - 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, - 0x1d, 0x1e, 0x1f, 0x20, - ]; - let public_key = public_key_from_seed(&seed); - let v = b"12:Hello World!"; - let seq = 1i64; - - let sig = sign_mutable_item(&seed, None, seq, v); - assert!(verify_mutable_item(&public_key, &sig, None, seq, v)); - } - - #[test] - fn test_sign_and_verify_with_salt() { - let seed: [u8; 32] = [0xAA; 32]; - let public_key = public_key_from_seed(&seed); - let salt = b"test-salt"; - let v = b"5:hello"; - let seq = 42i64; - - let sig = sign_mutable_item(&seed, Some(salt), seq, v); - assert!(verify_mutable_item(&public_key, &sig, Some(salt), seq, v)); - } - - #[test] - fn test_verify_rejects_wrong_key() { - let seed: [u8; 32] = [0x01; 32]; - let wrong_key: [u8; 32] = [0x02; 32]; - let v = b"i42e"; - let seq = 1i64; - - let sig = sign_mutable_item(&seed, None, seq, v); - // Wrong key should fail verification (wrong_key is random bytes, not a valid public key - // for the signing seed, so verification must fail). - assert!(!verify_mutable_item(&wrong_key, &sig, None, seq, v)); - } - - #[test] - fn test_verify_rejects_wrong_seq() { - let seed: [u8; 32] = [0x01; 32]; - let public_key = public_key_from_seed(&seed); - let v = b"i42e"; - - let sig = sign_mutable_item(&seed, None, 1, v); - // Different seq should fail - assert!(!verify_mutable_item(&public_key, &sig, None, 2, v)); - } - - #[test] - fn test_verify_rejects_wrong_value() { - let seed: [u8; 32] = [0x01; 32]; - let public_key = public_key_from_seed(&seed); - - let sig = sign_mutable_item(&seed, None, 1, b"i42e"); - // Different value should fail - assert!(!verify_mutable_item(&public_key, &sig, None, 1, b"i99e")); - } - - #[test] - fn test_verify_rejects_wrong_salt() { - let seed: [u8; 32] = [0x01; 32]; - let public_key = public_key_from_seed(&seed); - let v = b"i42e"; - - let sig = sign_mutable_item(&seed, Some(b"salt1"), 1, v); - // Different salt should fail - assert!(!verify_mutable_item( - &public_key, - &sig, - Some(b"salt2"), - 1, - v - )); - } - - #[test] - fn test_mutable_item_target_no_salt() { - let public_key: [u8; 32] = [0xAA; 32]; - let target = mutable_item_target(&public_key, None); - // Verify it is the SHA-1 of just the public key - let mut hasher = sha1w::Sha1::new(); - hasher.update(&public_key); - let expected = Id20::new(hasher.finish()); - assert_eq!(target, expected); - } - - #[test] - fn test_mutable_item_target_with_salt() { - let public_key: [u8; 32] = [0xBB; 32]; - let salt = b"my-salt"; - let target = mutable_item_target(&public_key, Some(salt)); - // Verify it is SHA-1 of public_key + salt - let mut hasher = sha1w::Sha1::new(); - hasher.update(&public_key); - hasher.update(salt); - let expected = Id20::new(hasher.finish()); - assert_eq!(target, expected); - } - - #[test] - fn test_public_key_from_seed_deterministic() { - let seed: [u8; 32] = [0x42; 32]; - let pk1 = public_key_from_seed(&seed); - let pk2 = public_key_from_seed(&seed); - assert_eq!(pk1, pk2); - } - - #[test] - fn test_sign_deterministic() { - let seed: [u8; 32] = [0x42; 32]; - let v = b"3:foo"; - let sig1 = sign_mutable_item(&seed, None, 1, v); - let sig2 = sign_mutable_item(&seed, None, 1, v); - // Ed25519 is deterministic (no random nonce) - assert_eq!(sig1, sig2); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/bprotocol.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/bprotocol.rs deleted file mode 100644 index 8a5c5fe0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/bprotocol.rs +++ /dev/null @@ -1,1557 +0,0 @@ -use std::{ - io::Write, - marker::PhantomData, - net::{SocketAddr, SocketAddrV4, SocketAddrV6}, -}; - -use bencode::{ByteBuf, ByteBufOwned}; -use buffers::ByteBufT; -use bytes::Bytes; -use clone_to_owned::CloneToOwned; -use librtbit_core::{ - compact_ip::{ - Compact, CompactListInBuffer, CompactSerialize, CompactSerializeFixedLen, CompactSocketAddr, - }, - hash_id::Id20, -}; -use serde::{ - Deserialize, Deserializer, Serialize, - de::{IgnoredAny, Unexpected}, -}; - -#[derive(Debug)] -enum MessageType { - Request, - Response, - Error, -} - -impl<'de> Deserialize<'de> for MessageType { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct Visitor; - impl serde::de::Visitor<'_> for Visitor { - type Value = MessageType; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, r#""q", "e" or "r" bencode string"#) - } - fn visit_bytes(self, v: &[u8]) -> Result - where - E: serde::de::Error, - { - let msg = match v { - b"q" => MessageType::Request, - b"r" => MessageType::Response, - b"e" => MessageType::Error, - _ => return Err(E::invalid_value(Unexpected::Bytes(v), &self)), - }; - Ok(msg) - } - } - deserializer.deserialize_bytes(Visitor {}) - } -} - -impl Serialize for MessageType { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - MessageType::Request => serializer.serialize_bytes(b"q"), - MessageType::Response => serializer.serialize_bytes(b"r"), - MessageType::Error => serializer.serialize_bytes(b"e"), - } - } -} - -#[derive(Debug)] -pub struct ErrorDescription { - pub code: i32, - pub description: BufT, -} - -impl CloneToOwned for ErrorDescription -where - BufT: CloneToOwned, -{ - type Target = ErrorDescription<::Target>; - - fn clone_to_owned(&self, within_buffer: Option<&Bytes>) -> Self::Target { - ErrorDescription { - code: self.code, - description: self.description.clone_to_owned(within_buffer), - } - } -} - -impl Serialize for ErrorDescription -where - BufT: Serialize, -{ - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeSeq; - let mut seq = serializer.serialize_seq(Some(2))?; - seq.serialize_element(&self.code)?; - seq.serialize_element(&self.description)?; - seq.end() - } -} - -impl<'de, BufT> Deserialize<'de> for ErrorDescription -where - BufT: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct Visitor { - phantom: PhantomData, - } - impl<'de, BufT> serde::de::Visitor<'de> for Visitor - where - BufT: Deserialize<'de>, - { - type Value = ErrorDescription; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, r#"a list [i32, string]"#) - } - fn visit_seq(self, mut seq: A) -> Result - where - A: serde::de::SeqAccess<'de>, - { - use serde::de::Error; - let code = match seq.next_element::()? { - Some(code) => code, - None => return Err(A::Error::invalid_length(0, &self)), - }; - let description = match seq.next_element::()? { - Some(code) => code, - None => return Err(A::Error::invalid_length(1, &self)), - }; - // The type doesn't matter here, we are just making sure the list is over. - if seq.next_element::()?.is_some() { - return Err(A::Error::invalid_length(3, &self)); - } - Ok(ErrorDescription { code, description }) - } - } - deserializer.deserialize_seq(Visitor { - phantom: PhantomData, - }) - } -} - -#[derive(Debug, Serialize, Deserialize)] -struct RawMessage { - #[serde(rename = "y")] - message_type: MessageType, - #[serde(rename = "t")] - transaction_id: BufT, - #[serde(rename = "e", skip_serializing_if = "Option::is_none")] - error: Option>, - #[serde(rename = "r", skip_serializing_if = "Option::is_none")] - response: Option, - #[serde(rename = "q", skip_serializing_if = "Option::is_none")] - method_name: Option, - #[serde(rename = "a", skip_serializing_if = "Option::is_none")] - arguments: Option, - #[serde(rename = "v", skip_serializing_if = "Option::is_none")] - version: Option, - #[serde(rename = "ip", skip_serializing_if = "Option::is_none")] - ip: Option, -} - -pub struct Node { - pub id: Id20, - pub addr: A, -} - -impl + Copy> Node { - pub fn as_socketaddr(&self) -> Node { - Node { - id: self.id, - addr: self.addr.into(), - } - } -} - -pub type CompactNodeInfo = CompactListInBuffer>; -pub type CompactNodeInfoOwned = CompactNodeInfo; - -impl CompactSerialize for Node { - type Slice = [u8; 26]; - - fn expecting() -> &'static str { - "26 bytes" - } - - fn as_slice(&self) -> Self::Slice { - let mut data = [0u8; 26]; - data[..20].copy_from_slice(&self.id.0); - data[20..26].copy_from_slice(self.addr.as_slice().as_ref()); - data - } - - fn from_slice(buf: &[u8]) -> Option { - if buf.len() != 26 { - return None; - } - Some(Self::from_slice_unchecked_len(buf)) - } - - fn from_slice_unchecked_len(buf: &[u8]) -> Self { - Node { - id: Id20::from_bytes(&buf[..20]).unwrap(), - addr: SocketAddrV4::from_slice_unchecked_len(&buf[20..26]), - } - } -} - -impl CompactSerializeFixedLen for Node { - fn fixed_len() -> usize { - 20 + A::fixed_len() - } -} - -impl CompactSerialize for Node { - type Slice = [u8; 38]; - - fn expecting() -> &'static str { - "38 bytes" - } - - fn as_slice(&self) -> Self::Slice { - let mut data = [0u8; 38]; - data[..20].copy_from_slice(&self.id.0); - data[20..38].copy_from_slice(self.addr.as_slice().as_ref()); - data - } - - fn from_slice(buf: &[u8]) -> Option { - if buf.len() != 38 { - return None; - } - Some(Self::from_slice_unchecked_len(buf)) - } - - fn from_slice_unchecked_len(buf: &[u8]) -> Self { - Node { - id: Id20::from_bytes(&buf[..20]).unwrap(), - addr: SocketAddrV6::from_slice_unchecked_len(&buf[20..38]), - } - } -} - -impl core::fmt::Debug for Node { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}={:?}", self.addr, self.id) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Want { - V4, - V6, - Both, - None, -} - -impl Serialize for Want { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - Want::V4 => ["n4"][..].serialize(serializer), - Want::V6 => ["n6"][..].serialize(serializer), - Want::Both => ["n4", "n6"][..].serialize(serializer), - Want::None => { - const EMPTY: [&str; 0] = []; - EMPTY[..].serialize(serializer) - } - } - } -} - -impl<'de> Deserialize<'de> for Want { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct V; - impl<'de> serde::de::Visitor<'de> for V { - type Value = Want; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, r#"a list with "n4", "n6" or both"#) - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: serde::de::SeqAccess<'de>, - { - let mut want_v4 = false; - let mut want_v6 = false; - while let Some(item) = seq.next_element::<&[u8]>()? { - match item { - b"n4" => want_v4 = true, - b"n6" => want_v6 = true, - _ => continue, - } - } - match (want_v4, want_v6) { - (true, true) => Ok(Want::Both), - (true, false) => Ok(Want::V4), - (false, true) => Ok(Want::V6), - (false, false) => Ok(Want::None), - } - } - } - deserializer.deserialize_seq(V) - } -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct FindNodeRequest { - pub id: Id20, - pub target: Id20, - #[serde(skip_serializing_if = "Option::is_none")] - pub want: Option, -} - -#[derive(Debug, Serialize, Deserialize, Default)] -pub struct Response { - #[serde(skip_serializing_if = "Option::is_none")] - pub values: Option>, - pub id: Id20, - #[serde(skip_serializing_if = "Option::is_none")] - pub nodes: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub nodes6: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub token: Option, - // BEP 44 mutable item response fields. - /// The stored bencoded value (BEP 44). - #[serde(skip_serializing_if = "Option::is_none")] - pub v: Option, - /// 32-byte Ed25519 public key (BEP 44 mutable items). - #[serde(skip_serializing_if = "Option::is_none")] - pub k: Option, - /// 64-byte Ed25519 signature (BEP 44 mutable items). - #[serde(skip_serializing_if = "Option::is_none")] - pub sig: Option, - /// Sequence number (BEP 44 mutable items). - #[serde(skip_serializing_if = "Option::is_none")] - pub seq: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct GetPeersRequest { - pub id: Id20, - pub info_hash: Id20, - #[serde(skip_serializing_if = "Option::is_none")] - pub want: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct PingRequest { - pub id: Id20, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct AnnouncePeer { - pub id: Id20, - pub implied_port: u8, - pub info_hash: Id20, - pub port: u16, - pub token: BufT, -} - -/// BEP 44: get query arguments for mutable/immutable DHT items. -#[derive(Debug, Serialize, Deserialize)] -pub struct Bep44GetRequest { - pub id: Id20, - /// SHA-1 hash of the public key (+ optional salt) for mutable items, - /// or SHA-1 of the bencoded value for immutable items. - pub target: Id20, - /// If present, only return the stored value when its seq > this value. - #[serde(skip_serializing_if = "Option::is_none")] - pub seq: Option, -} - -/// BEP 44: put query arguments for mutable DHT items. -#[derive(Debug, Serialize, Deserialize)] -pub struct Bep44PutRequest { - pub id: Id20, - /// Write token obtained from a prior get response. - pub token: BufT, - /// 32-byte Ed25519 public key. - pub k: BufT, - /// 64-byte Ed25519 signature. - pub sig: BufT, - /// Sequence number. - pub seq: i64, - /// The bencoded value to store (max 1000 bytes). - pub v: BufT, - /// Optional salt (used in target computation). - #[serde(skip_serializing_if = "Option::is_none")] - pub salt: Option, - /// Optional compare-and-swap: only overwrite if stored seq == cas. - #[serde(skip_serializing_if = "Option::is_none")] - pub cas: Option, -} - -#[derive(Debug)] -pub struct Message { - pub kind: MessageKind, - pub transaction_id: BufT, - pub version: Option, - pub ip: Option, -} - -impl Message { - // This implies that the transaction id was generated by us. - pub fn get_our_transaction_id(&self) -> Option { - let tid = self.transaction_id.as_ref(); - if tid.len() != 2 { - return None; - } - let tid = ((tid[0] as u16) << 8) + (tid[1] as u16); - Some(tid) - } -} - -pub enum MessageKind { - Error(ErrorDescription), - GetPeersRequest(GetPeersRequest), - FindNodeRequest(FindNodeRequest), - Response(Response), - PingRequest(PingRequest), - AnnouncePeer(AnnouncePeer), - Bep44GetRequest(Bep44GetRequest), - Bep44PutRequest(Bep44PutRequest), -} - -impl core::fmt::Debug for MessageKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Error(e) => write!(f, "{e:?}"), - Self::GetPeersRequest(r) => write!(f, "{r:?}"), - Self::FindNodeRequest(r) => write!(f, "{r:?}"), - Self::Response(r) => write!(f, "{r:?}"), - Self::PingRequest(r) => write!(f, "{r:?}"), - Self::AnnouncePeer(r) => write!(f, "{r:?}"), - Self::Bep44GetRequest(r) => write!(f, "{r:?}"), - Self::Bep44PutRequest(r) => write!(f, "{r:?}"), - } - } -} - -pub fn serialize_message<'a, W: Write, BufT: ByteBufT + From<&'a [u8]>>( - writer: &mut W, - transaction_id: BufT, - version: Option, - ip: Option, - kind: MessageKind, -) -> crate::Result<()> { - let ip = ip.map(Compact); - match kind { - MessageKind::Error(e) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Error, - transaction_id, - error: Some(e), - response: None, - method_name: None, - version, - ip, - arguments: None, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::GetPeersRequest(req) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Request, - transaction_id, - error: None, - response: None, - method_name: Some(BufT::from(b"get_peers")), - arguments: Some(req), - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::FindNodeRequest(req) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Request, - transaction_id, - error: None, - response: None, - method_name: Some(BufT::from(b"find_node")), - arguments: Some(req), - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::Response(resp) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Response, - transaction_id, - error: None, - response: Some(resp), - method_name: None, - arguments: None, - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::PingRequest(ping) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Request, - transaction_id, - error: None, - response: None, - method_name: Some(BufT::from(b"ping")), - arguments: Some(ping), - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::AnnouncePeer(announce) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Request, - transaction_id, - error: None, - response: None, - method_name: Some(BufT::from(b"announce_peer")), - arguments: Some(announce), - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::Bep44GetRequest(req) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Request, - transaction_id, - error: None, - response: None, - method_name: Some(BufT::from(b"get")), - arguments: Some(req), - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - MessageKind::Bep44PutRequest(req) => { - let msg: RawMessage = RawMessage { - message_type: MessageType::Request, - transaction_id, - error: None, - response: None, - method_name: Some(BufT::from(b"put")), - arguments: Some(req), - ip, - version, - }; - Ok(bencode::bencode_serialize_to_writer(msg, writer)?) - } - } -} - -pub fn deserialize_message<'de, BufT>(buf: &'de [u8]) -> anyhow::Result> -where - BufT: ByteBufT + Deserialize<'de>, -{ - let de: RawMessage = bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - match de.message_type { - MessageType::Request => match (&de.arguments, &de.method_name, &de.response, &de.error) { - (Some(_), Some(method_name), None, None) => match method_name.as_ref() { - b"find_node" => { - let de: RawMessage = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::FindNodeRequest(de.arguments.unwrap()), - }) - } - b"get_peers" => { - let de: RawMessage = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::GetPeersRequest(de.arguments.unwrap()), - }) - } - b"ping" => { - let de: RawMessage = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::PingRequest(de.arguments.unwrap()), - }) - } - b"announce_peer" => { - let de: RawMessage> = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::AnnouncePeer(de.arguments.unwrap()), - }) - } - b"get" => { - let de: RawMessage = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::Bep44GetRequest(de.arguments.unwrap()), - }) - } - b"put" => { - let de: RawMessage> = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::Bep44PutRequest(de.arguments.unwrap()), - }) - } - other => anyhow::bail!("unsupported method {:?}", ByteBuf(other)), - }, - _ => anyhow::bail!( - "cannot deserialize message as request, expected exactly \"a\" and \"q\" to be set. Message: {:?}", - de - ), - }, - MessageType::Response => match (&de.arguments, &de.method_name, &de.response, &de.error) { - // some peers are sending method name against the protocol, so ignore it. - (None, _, Some(_), None) => { - let de: RawMessage> = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::Response(de.response.unwrap()), - }) - } - _ => anyhow::bail!( - "cannot deserialize message as response, expected exactly \"r\" to be set. Message: {:?}", - de - ), - }, - MessageType::Error => match (&de.arguments, &de.method_name, &de.response, &de.error) { - // some peers are sending method name against the protocol, so ignore it. - (None, _, None, Some(_)) => { - let de: RawMessage> = - bencode::from_bytes(buf).map_err(|e| e.into_anyhow())?; - Ok(Message { - transaction_id: de.transaction_id, - version: de.version, - ip: de.ip.map(|c| c.0), - kind: MessageKind::Error(de.error.unwrap()), - }) - } - _ => anyhow::bail!( - "cannot deserialize message as error, expected exactly \"e\" to be set. Message: {:?}", - de - ), - }, - } -} - -#[cfg(test)] -mod tests { - use std::io::Write; - - use crate::bprotocol::{self, Want}; - use bencode::{ByteBuf, bencode_serialize_to_writer}; - - // Dumped with wireshark. - const FIND_NODE_REQUEST: &[u8] = - include_bytes!("../resources/test_requests/find_node_request.bin"); - const GET_PEERS_REQUEST_0: &[u8] = - include_bytes!("../resources/test_requests/get_peers_request_0.bin"); - const GET_PEERS_REQUEST_1: &[u8] = - include_bytes!("../resources/test_requests/get_peers_request_1.bin"); - const FIND_NODE_RESPONSE_1: &[u8] = - include_bytes!("../resources/test_requests/find_node_response_1.bin"); - const FIND_NODE_RESPONSE_2: &[u8] = - include_bytes!("../resources/test_requests/find_node_response_2.bin"); - const FIND_NODE_RESPONSE_3: &[u8] = - include_bytes!("../resources/test_requests/find_node_response_3.bin"); - - fn write(filename: &str, data: &[u8]) { - let full = format!("/tmp/{filename}.bin"); - let mut f = std::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(full) - .unwrap(); - f.write_all(data).unwrap() - } - - fn debug_bencode(name: &str, data: &[u8]) { - println!( - "{name}: {:#?}", - bencode::dyn_from_bytes::(data).unwrap() - ); - } - - fn test_deserialize_then_serialize(data: &[u8], name: &'static str) { - dbg!(bencode::dyn_from_bytes::(data).unwrap()); - let bprotocol::Message { - kind, - transaction_id, - version, - ip, - } = dbg!(bprotocol::deserialize_message::(data).unwrap()); - let mut buf = Vec::new(); - bprotocol::serialize_message(&mut buf, transaction_id, version, ip, kind).unwrap(); - - if buf.as_slice() != data { - write(&format!("{name}-serialized"), buf.as_slice()); - write(&format!("{name}-expected"), data); - panic!( - "{} results don't match, dumped to /tmp/{}-*.bin", - name, name - ) - } - } - - #[test] - fn serialize_then_deserialize_then_serialize_error() { - let mut buf = Vec::new(); - let transaction_id = ByteBuf(b"123"); - bprotocol::serialize_message( - &mut buf, - transaction_id, - None, - None, - bprotocol::MessageKind::Error(bprotocol::ErrorDescription { - code: 201, - description: ByteBuf(b"Some error"), - }), - ) - .unwrap(); - - let bprotocol::Message { - transaction_id, - kind, - .. - } = bprotocol::deserialize_message::(&buf).unwrap(); - - let mut buf2 = Vec::new(); - bprotocol::serialize_message(&mut buf2, transaction_id, None, None, kind).unwrap(); - - if buf.as_slice() != buf2.as_slice() { - write("error-serialized", buf.as_slice()); - write("error-serialized-again", buf2.as_slice()); - panic!("results don't match, dumped to /tmp/error-serialized-*.bin",) - } - } - - #[test] - fn deserialize_request_find_node() { - test_deserialize_then_serialize(FIND_NODE_REQUEST, "find_node_request") - } - - #[test] - fn deserialize_request_get_peers() { - test_deserialize_then_serialize(GET_PEERS_REQUEST_0, "get_peers_request_0") - } - - #[test] - fn deserialize_response_find_node() { - test_deserialize_then_serialize(FIND_NODE_RESPONSE_1, "find_node_response") - } - - #[test] - fn deserialize_response_find_node_2() { - test_deserialize_then_serialize(FIND_NODE_RESPONSE_2, "find_node_response_2") - } - - #[test] - fn deserialize_response_find_node_3() { - test_deserialize_then_serialize(FIND_NODE_RESPONSE_3, "find_node_response_3") - } - - #[test] - fn deserialize_request_get_peers_request_1() { - test_deserialize_then_serialize(GET_PEERS_REQUEST_1, "get_peers_request_1") - } - - #[test] - fn test_announce() { - let ann = b"d1:ad2:id20:abcdefghij012345678912:implied_porti1e9:info_hash20:mnopqrstuvwxyz1234564:porti6881e5:token8:aoeusnthe1:q13:announce_peer1:t2:aa1:y1:qe"; - let msg = bprotocol::deserialize_message::(ann).unwrap(); - match &msg.kind { - bprotocol::MessageKind::AnnouncePeer(ann) => { - dbg!(&ann); - } - _ => panic!("wrong kind"), - } - let mut buf = Vec::new(); - bprotocol::serialize_message(&mut buf, msg.transaction_id, msg.version, msg.ip, msg.kind) - .unwrap(); - assert_eq!(ann[..], buf[..]); - } - - #[test] - fn deserialize_bencode_packets_captured_from_wireshark() { - debug_bencode("req: find_node", FIND_NODE_REQUEST); - debug_bencode("req: get_peers", GET_PEERS_REQUEST_0); - debug_bencode("resp from the requesting node", FIND_NODE_RESPONSE_1); - debug_bencode("resp from some random IP", FIND_NODE_RESPONSE_2); - debug_bencode("another resp from some random IP", FIND_NODE_RESPONSE_3); - debug_bencode("req to another node", GET_PEERS_REQUEST_1); - } - - #[test] - fn serde_want_deserialize() { - assert_eq!(bencode::from_bytes::(b"l2:n4e").unwrap(), Want::V4); - assert_eq!(bencode::from_bytes::(b"l2:n6e").unwrap(), Want::V6); - assert_eq!( - bencode::from_bytes::(b"l2:n42:n6e").unwrap(), - Want::Both - ); - assert_eq!( - bencode::from_bytes::(b"l2:aa2:bbe").unwrap(), - Want::None - ); - } - - #[test] - fn serde_want_serialize() { - let mut w = Vec::new(); - bencode_serialize_to_writer(Want::V6, &mut w).unwrap(); - assert_eq!(&w, b"l2:n6e"); - - let mut w = Vec::new(); - bencode_serialize_to_writer(Want::V4, &mut w).unwrap(); - assert_eq!(&w, b"l2:n4e"); - - let mut w = Vec::new(); - bencode_serialize_to_writer(Want::Both, &mut w).unwrap(); - assert_eq!(&w, b"l2:n42:n6e"); - - let mut w = Vec::new(); - bencode_serialize_to_writer(Want::None, &mut w).unwrap(); - assert_eq!(&w, b"le") - } - - // ----------------------------------------------------------------------- - // test_serialize_find_node_request - // ----------------------------------------------------------------------- - #[test] - fn test_serialize_find_node_request() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0xAA; 20]); - let target = Id20::new([0xBB; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"tx"), - None, - None, - bprotocol::MessageKind::FindNodeRequest(bprotocol::FindNodeRequest { - id, - target, - want: None, - }), - ) - .unwrap(); - - // Deserialize and verify. - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::FindNodeRequest(req) => { - assert_eq!(req.id, id); - assert_eq!(req.target, target); - assert!(req.want.is_none()); - } - _ => panic!("expected FindNodeRequest"), - } - assert_eq!(msg.transaction_id.0, b"tx"); - } - - // ----------------------------------------------------------------------- - // test_serialize_get_peers_request - // ----------------------------------------------------------------------- - #[test] - fn test_serialize_get_peers_request() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x11; 20]); - let info_hash = Id20::new([0x22; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"ab"), - None, - None, - bprotocol::MessageKind::GetPeersRequest(bprotocol::GetPeersRequest { - id, - info_hash, - want: Some(Want::V4), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::GetPeersRequest(req) => { - assert_eq!(req.id, id); - assert_eq!(req.info_hash, info_hash); - assert_eq!(req.want, Some(Want::V4)); - } - _ => panic!("expected GetPeersRequest"), - } - } - - // ----------------------------------------------------------------------- - // test_serialize_announce_peer_request - // ----------------------------------------------------------------------- - #[test] - fn test_serialize_announce_peer_request() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new(*b"abcdefghij0123456789"); - let info_hash = Id20::new(*b"mnopqrstuvwxyz123456"); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"xy"), - None, - None, - bprotocol::MessageKind::AnnouncePeer(bprotocol::AnnouncePeer { - id, - implied_port: 1, - info_hash, - port: 6881, - token: ByteBuf(b"tok1"), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::AnnouncePeer(ann) => { - assert_eq!(ann.id, id); - assert_eq!(ann.info_hash, info_hash); - assert_eq!(ann.implied_port, 1); - assert_eq!(ann.port, 6881); - assert_eq!(ann.token.0, b"tok1"); - } - _ => panic!("expected AnnouncePeer"), - } - } - - // ----------------------------------------------------------------------- - // test_deserialize_error_response - // ----------------------------------------------------------------------- - #[test] - fn test_deserialize_error_response() { - // Serialize an error, then deserialize it. - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"er"), - None, - None, - bprotocol::MessageKind::Error(bprotocol::ErrorDescription { - code: 202, - description: ByteBuf(b"Server Error"), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Error(e) => { - assert_eq!(e.code, 202); - assert_eq!(e.description.0, b"Server Error"); - } - _ => panic!("expected Error"), - } - } - - // ----------------------------------------------------------------------- - // test_deserialize_malformed_message - // ----------------------------------------------------------------------- - #[test] - fn test_deserialize_malformed_message() { - // Completely invalid bencode. - let result = bprotocol::deserialize_message::(b"not valid bencode"); - assert!(result.is_err()); - - // Valid bencode but not a valid DHT message (missing required fields). - let result = bprotocol::deserialize_message::(b"d1:yi1ee"); - assert!(result.is_err()); - - // Empty input. - let result = bprotocol::deserialize_message::(b""); - assert!(result.is_err()); - } - - // ----------------------------------------------------------------------- - // test_serialize_deserialize_roundtrip_all_message_types - // ----------------------------------------------------------------------- - #[test] - fn test_serialize_deserialize_roundtrip_all_message_types() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x33; 20]); - let target = Id20::new([0x44; 20]); - - // 1. Ping request - { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"p1"), - None, - None, - bprotocol::MessageKind::PingRequest(bprotocol::PingRequest { id }), - ) - .unwrap(); - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(matches!(msg.kind, bprotocol::MessageKind::PingRequest(_))); - - // Re-serialize and check byte equality. - let mut buf2 = Vec::new(); - bprotocol::serialize_message( - &mut buf2, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - assert_eq!(buf, buf2, "ping roundtrip mismatch"); - } - - // 2. FindNode request - { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"f1"), - None, - None, - bprotocol::MessageKind::FindNodeRequest(bprotocol::FindNodeRequest { - id, - target, - want: Some(Want::Both), - }), - ) - .unwrap(); - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(matches!( - msg.kind, - bprotocol::MessageKind::FindNodeRequest(_) - )); - let mut buf2 = Vec::new(); - bprotocol::serialize_message( - &mut buf2, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - assert_eq!(buf, buf2, "find_node roundtrip mismatch"); - } - - // 3. GetPeers request - { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"g1"), - None, - None, - bprotocol::MessageKind::GetPeersRequest(bprotocol::GetPeersRequest { - id, - info_hash: target, - want: Some(Want::V6), - }), - ) - .unwrap(); - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(matches!( - msg.kind, - bprotocol::MessageKind::GetPeersRequest(_) - )); - let mut buf2 = Vec::new(); - bprotocol::serialize_message( - &mut buf2, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - assert_eq!(buf, buf2, "get_peers roundtrip mismatch"); - } - - // 4. Error - { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"e1"), - None, - None, - bprotocol::MessageKind::Error(bprotocol::ErrorDescription { - code: 201, - description: ByteBuf(b"A Generic Error Occurred"), - }), - ) - .unwrap(); - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(matches!(msg.kind, bprotocol::MessageKind::Error(_))); - let mut buf2 = Vec::new(); - bprotocol::serialize_message( - &mut buf2, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - assert_eq!(buf, buf2, "error roundtrip mismatch"); - } - - // 5. Response (with only id, no nodes/values) - { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"r1"), - None, - None, - bprotocol::MessageKind::Response(bprotocol::Response { - id, - ..Default::default() - }), - ) - .unwrap(); - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(matches!(msg.kind, bprotocol::MessageKind::Response(_))); - let mut buf2 = Vec::new(); - bprotocol::serialize_message( - &mut buf2, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - assert_eq!(buf, buf2, "response roundtrip mismatch"); - } - - // 6. AnnouncePeer request - { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"a1"), - None, - None, - bprotocol::MessageKind::AnnouncePeer(bprotocol::AnnouncePeer { - id, - implied_port: 0, - info_hash: target, - port: 8080, - token: ByteBuf(b"mytoken"), - }), - ) - .unwrap(); - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(matches!(msg.kind, bprotocol::MessageKind::AnnouncePeer(_))); - let mut buf2 = Vec::new(); - bprotocol::serialize_message( - &mut buf2, - msg.transaction_id, - msg.version, - msg.ip, - msg.kind, - ) - .unwrap(); - assert_eq!(buf, buf2, "announce_peer roundtrip mismatch"); - } - } - - // ----------------------------------------------------------------------- - // test_serialize_ping_request - // ----------------------------------------------------------------------- - #[test] - fn test_serialize_ping_request() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x55; 20]); - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"pi"), - None, - None, - bprotocol::MessageKind::PingRequest(bprotocol::PingRequest { id }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::PingRequest(req) => { - assert_eq!(req.id, id); - } - _ => panic!("expected PingRequest"), - } - } - - // ----------------------------------------------------------------------- - // test_deserialize_unsupported_method - // ----------------------------------------------------------------------- - #[test] - fn test_deserialize_unsupported_method() { - // Manually craft a bencode request with an unknown method name. - // d1:ad2:id20:aaaaaaaaaaaaaaaaaaaae1:q7:unknown1:t2:xx1:y1:qe - let raw = b"d1:ad2:id20:aaaaaaaaaaaaaaaaaaaae1:q7:unknown1:t2:xx1:y1:qe"; - let result = bprotocol::deserialize_message::(raw); - assert!(result.is_err()); - } - - // ----------------------------------------------------------------------- - // test_message_with_version_field - // ----------------------------------------------------------------------- - #[test] - fn test_message_with_version_field() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x77; 20]); - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"v1"), - Some(ByteBuf(b"rq01")), - None, - bprotocol::MessageKind::PingRequest(bprotocol::PingRequest { id }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - assert!(msg.version.is_some()); - assert_eq!(msg.version.unwrap().0, b"rq01"); - } - - // ----------------------------------------------------------------------- - // test_error_description_various_codes - // ----------------------------------------------------------------------- - #[test] - fn test_error_description_various_codes() { - // BEP 5 defines error codes: - // 201 = Generic Error, 202 = Server Error, 203 = Protocol Error, 204 = Method Unknown - for (code, desc) in [ - (201, "Generic Error"), - (202, "Server Error"), - (203, "Protocol Error"), - (204, "Method Unknown"), - ] { - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"ee"), - None, - None, - bprotocol::MessageKind::Error(bprotocol::ErrorDescription { - code, - description: ByteBuf(desc.as_bytes()), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Error(e) => { - assert_eq!(e.code, code); - assert_eq!(e.description.0, desc.as_bytes()); - } - _ => panic!("expected Error for code {code}"), - } - } - } - - // ----------------------------------------------------------------------- - // BEP 44 message type tests - // ----------------------------------------------------------------------- - - #[test] - fn test_serialize_bep44_get_request() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x11; 20]); - let target = Id20::new([0x22; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"g4"), - None, - None, - bprotocol::MessageKind::Bep44GetRequest(bprotocol::Bep44GetRequest { - id, - target, - seq: None, - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Bep44GetRequest(req) => { - assert_eq!(req.id, id); - assert_eq!(req.target, target); - assert!(req.seq.is_none()); - } - _ => panic!("expected Bep44GetRequest"), - } - } - - #[test] - fn test_serialize_bep44_get_request_with_seq() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x11; 20]); - let target = Id20::new([0x22; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"g5"), - None, - None, - bprotocol::MessageKind::Bep44GetRequest(bprotocol::Bep44GetRequest { - id, - target, - seq: Some(42), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Bep44GetRequest(req) => { - assert_eq!(req.id, id); - assert_eq!(req.target, target); - assert_eq!(req.seq, Some(42)); - } - _ => panic!("expected Bep44GetRequest"), - } - } - - #[test] - fn test_serialize_bep44_put_request() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x33; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"p4"), - None, - None, - bprotocol::MessageKind::Bep44PutRequest(bprotocol::Bep44PutRequest { - id, - token: ByteBuf(b"tok123"), - k: ByteBuf(&[0xAA; 32]), - sig: ByteBuf(&[0xBB; 64]), - seq: 7, - v: ByteBuf(b"5:hello"), - salt: None, - cas: None, - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Bep44PutRequest(req) => { - assert_eq!(req.id, id); - assert_eq!(req.token.0, b"tok123"); - assert_eq!(req.k.0.len(), 32); - assert_eq!(req.sig.0.len(), 64); - assert_eq!(req.seq, 7); - assert_eq!(req.v.0, b"5:hello"); - assert!(req.salt.is_none()); - assert!(req.cas.is_none()); - } - _ => panic!("expected Bep44PutRequest"), - } - } - - #[test] - fn test_serialize_bep44_put_request_with_salt_and_cas() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x44; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"p5"), - None, - None, - bprotocol::MessageKind::Bep44PutRequest(bprotocol::Bep44PutRequest { - id, - token: ByteBuf(b"token"), - k: ByteBuf(&[0xCC; 32]), - sig: ByteBuf(&[0xDD; 64]), - seq: 10, - v: ByteBuf(b"i42e"), - salt: Some(ByteBuf(b"my-salt")), - cas: Some(9), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Bep44PutRequest(req) => { - assert_eq!(req.id, id); - assert_eq!(req.seq, 10); - assert_eq!(req.salt.as_ref().unwrap().0, b"my-salt"); - assert_eq!(req.cas, Some(9)); - } - _ => panic!("expected Bep44PutRequest"), - } - } - - #[test] - fn test_bep44_get_roundtrip() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x55; 20]); - let target = Id20::new([0x66; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"rt"), - None, - None, - bprotocol::MessageKind::Bep44GetRequest(bprotocol::Bep44GetRequest { - id, - target, - seq: Some(100), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - let mut buf2 = Vec::new(); - bprotocol::serialize_message(&mut buf2, msg.transaction_id, msg.version, msg.ip, msg.kind) - .unwrap(); - assert_eq!(buf, buf2, "BEP 44 get roundtrip mismatch"); - } - - #[test] - fn test_bep44_put_roundtrip() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x77; 20]); - - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"rp"), - None, - None, - bprotocol::MessageKind::Bep44PutRequest(bprotocol::Bep44PutRequest { - id, - token: ByteBuf(b"write-token"), - k: ByteBuf(&[0xEE; 32]), - sig: ByteBuf(&[0xFF; 64]), - seq: 99, - v: ByteBuf(b"12:Hello World!"), - salt: Some(ByteBuf(b"salty")), - cas: Some(98), - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - let mut buf2 = Vec::new(); - bprotocol::serialize_message(&mut buf2, msg.transaction_id, msg.version, msg.ip, msg.kind) - .unwrap(); - assert_eq!(buf, buf2, "BEP 44 put roundtrip mismatch"); - } - - #[test] - fn test_response_with_bep44_fields() { - use librtbit_core::hash_id::Id20; - - let id = Id20::new([0x88; 20]); - - // A BEP 44 get response includes v, k, sig, seq along with standard fields. - let mut buf = Vec::new(); - bprotocol::serialize_message( - &mut buf, - ByteBuf(b"br"), - None, - None, - bprotocol::MessageKind::Response(bprotocol::Response { - id, - token: Some(ByteBuf(b"tok")), - v: Some(ByteBuf(b"5:hello")), - k: Some(ByteBuf(&[0xAA; 32])), - sig: Some(ByteBuf(&[0xBB; 64])), - seq: Some(42), - ..Default::default() - }), - ) - .unwrap(); - - let msg = bprotocol::deserialize_message::(&buf).unwrap(); - match &msg.kind { - bprotocol::MessageKind::Response(resp) => { - assert_eq!(resp.id, id); - assert_eq!(resp.v.as_ref().unwrap().0, b"5:hello"); - assert_eq!(resp.k.as_ref().unwrap().0.len(), 32); - assert_eq!(resp.sig.as_ref().unwrap().0.len(), 64); - assert_eq!(resp.seq, Some(42)); - } - _ => panic!("expected Response"), - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/dht.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/dht.rs deleted file mode 100644 index d92d265f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/dht.rs +++ /dev/null @@ -1,1482 +0,0 @@ -use std::{ - cmp::Reverse, - net::{Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}, - str::FromStr, - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, - task::Poll, - time::{Duration, Instant}, -}; - -use crate::{ - Error, INACTIVITY_TIMEOUT, REQUERY_INTERVAL, RESPONSE_TIMEOUT, - bprotocol::{ - self, AnnouncePeer, CompactNodeInfo, CompactNodeInfoOwned, ErrorDescription, - FindNodeRequest, GetPeersRequest, Message, MessageKind, Node, PingRequest, Response, Want, - }, - peer_store::PeerStore, - routing_table::{InsertResult, NodeStatus, RoutingTable}, -}; -use backon::{ExponentialBuilder, Retryable}; -use bencode::ByteBufOwned; -use dashmap::DashMap; -use futures::{ - FutureExt, Stream, StreamExt, TryFutureExt, future::BoxFuture, stream::FuturesUnordered, -}; - -use leaky_bucket::RateLimiter; -use librqbit_dualstack_sockets::{BindDevice, UdpSocket}; -use librtbit_core::{ - compact_ip::{CompactSerialize, CompactSerializeFixedLen}, - crate_version, - hash_id::Id20, - peer_id::generate_azereus_style, - spawn_utils::{spawn, spawn_with_cancel}, -}; -use parking_lot::RwLock; - -use serde::Serialize; -use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender, channel, unbounded_channel}; - -use tokio_util::sync::CancellationToken; -use tracing::{Instrument, debug, debug_span, error, info, trace, warn}; - -fn now() -> Instant { - Instant::now() -} - -#[derive(Debug, Serialize)] -pub struct DhtStats { - #[serde(serialize_with = "crate::utils::serialize_id20")] - pub id: Id20, - pub outstanding_requests: usize, - pub routing_table_size: usize, - pub routing_table_size_v6: usize, -} - -struct OutstandingRequest { - done: tokio::sync::oneshot::Sender>, -} - -pub struct WorkerSendRequest { - // If this is set, we are tracking the response in inflight_by_transaction_id - our_tid: Option, - message: Message, - addr: SocketAddr, -} - -#[derive(Debug)] -struct MaybeUsefulNode { - id: Id20, - addr: SocketAddr, - last_request: Instant, - last_response: Option, - errors_in_a_row: usize, - returned_peers: bool, -} - -fn make_rate_limiter() -> RateLimiter { - // TODO: move to configuration, i'm lazy. - let dht_queries_per_second = std::env::var("DHT_QUERIES_PER_SECOND") - .map(|v| v.parse().expect("couldn't parse DHT_QUERIES_PER_SECOND")) - .unwrap_or(250usize); - - let per_100_ms = dht_queries_per_second / 10; - - RateLimiter::builder() - .initial(per_100_ms) - .max(dht_queries_per_second) - .interval(Duration::from_millis(100)) - .fair(false) - .refill(per_100_ms) - .build() -} - -trait RecursiveRequestCallbacks: Sized + Send + Sync + 'static { - fn on_request_start(&self, req: &RecursiveRequest, target_node: Id20, addr: SocketAddr); - fn on_request_end( - &self, - req: &RecursiveRequest, - target_node: Id20, - addr: SocketAddr, - resp: &crate::Result, - ); -} - -struct RecursiveRequestCallbacksGetPeers { - // Id20::from_str("00000fffffffffffffffffffffffffffffffffff").unwrap() - min_distance_to_announce: Id20, - announce_port: Option, -} - -impl RecursiveRequestCallbacks for RecursiveRequestCallbacksGetPeers { - fn on_request_start(&self, _: &RecursiveRequest, _: Id20, _: SocketAddr) {} - - fn on_request_end( - &self, - req: &RecursiveRequest, - target_node: Id20, - addr: SocketAddr, - resp: &crate::Result, - ) { - let announce_port = match self.announce_port { - Some(a) => a, - None => return, - }; - let resp = match resp { - Ok(ResponseOrError::Response(resp)) => resp, - _ => return, - }; - let token = match &resp.token { - Some(token) => token, - None => return, - }; - if req.info_hash.distance(&target_node) > self.min_distance_to_announce { - trace!( - "not announcing, {:?} is too far from {:?}", - target_node, req.info_hash - ); - return; - } - let (tid, message) = req.dht.create_request( - Request::Announce { - info_hash: req.info_hash, - token: token.clone(), - port: announce_port, - }, - addr, - ); - - let _ = req.dht.worker_sender.send(WorkerSendRequest { - our_tid: Some(tid), - message, - addr, - }); - } -} - -struct RecursiveRequestCallbacksFindNodes {} -impl RecursiveRequestCallbacks for RecursiveRequestCallbacksFindNodes { - fn on_request_start(&self, req: &RecursiveRequest, target_node: Id20, addr: SocketAddr) { - let mut rt = req.dht.get_table_for_addr(addr).write(); - match rt.add_node(target_node, addr) { - InsertResult::WasExisting | InsertResult::ReplacedBad(_) | InsertResult::Added => { - rt.mark_outgoing_request(&target_node, now()); - } - InsertResult::Ignored => {} - } - } - - fn on_request_end( - &self, - req: &RecursiveRequest, - target_node: Id20, - addr: SocketAddr, - resp: &crate::Result, - ) { - let mut table = req.dht.get_table_for_addr(addr).write(); - if resp.is_ok() { - table.mark_response(&target_node, now()); - } else { - table.mark_error(&target_node); - } - } -} - -struct RecursiveRequest { - max_depth: usize, - useful_nodes_limit: usize, - info_hash: Id20, - request: Request, - dht: Arc, - useful_nodes: RwLock>, - peer_tx: tokio::sync::mpsc::UnboundedSender, - node_tx: tokio::sync::mpsc::UnboundedSender<(Option, SocketAddr, usize)>, - callbacks: C, -} - -pub struct RequestPeersStream { - rx: tokio::sync::mpsc::UnboundedReceiver, - cancel_join_handle_v4: tokio::task::JoinHandle<()>, - cancel_join_handle_v6: tokio::task::JoinHandle<()>, -} - -impl RequestPeersStream { - fn new(dht: Arc, info_hash: Id20, announce_port: Option) -> Self { - let (peer_tx, peer_rx) = unbounded_channel(); - let make = |is_v4: bool, dht: Arc, peer_tx: UnboundedSender| { - let (node_tx, node_rx) = unbounded_channel(); - let rp = Arc::new(RecursiveRequest { - max_depth: 4, - info_hash, - useful_nodes_limit: 256, - request: Request::GetPeers(info_hash), - dht, - useful_nodes: RwLock::new(Vec::new()), - peer_tx, - node_tx, - callbacks: RecursiveRequestCallbacksGetPeers { - min_distance_to_announce: Id20::from_str( - "0000ffffffffffffffffffffffffffffffffffff", - ) - .unwrap(), - announce_port, - }, - }); - rp.request_peers_forever(node_rx, is_v4) - }; - - let v4 = make(true, dht.clone(), peer_tx.clone()); - let v6 = make(false, dht, peer_tx); - - Self { - rx: peer_rx, - cancel_join_handle_v4: v4, - cancel_join_handle_v6: v6, - } - } -} - -impl Drop for RequestPeersStream { - fn drop(&mut self) { - self.cancel_join_handle_v4.abort(); - self.cancel_join_handle_v6.abort(); - } -} - -impl Stream for RequestPeersStream { - type Item = SocketAddr; - - #[inline(never)] - fn poll_next( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> Poll> { - self.rx.poll_recv(cx) - } -} - -impl RecursiveRequest { - async fn find_node_for_routing_table( - dht: Arc, - target: Id20, - addrs: impl Iterator, - ) -> crate::Result<()> { - let (node_tx, mut node_rx) = unbounded_channel(); - let req = RecursiveRequest { - max_depth: 4, - info_hash: target, - request: Request::FindNode(target), - dht, - useful_nodes_limit: 32, - useful_nodes: RwLock::new(Vec::new()), - peer_tx: unbounded_channel().0, - node_tx, - callbacks: RecursiveRequestCallbacksFindNodes {}, - }; - - let request_one = |id, addr, depth| { - req.request_one(id, addr, depth) - .map_err(|e| { - debug!("error: {e:#}"); - e - }) - .instrument(debug_span!( - "find_node", - target = format!("{target:?}"), - addr = addr.to_string() - )) - }; - - let mut futs = FuturesUnordered::new(); - - let mut initial_addrs = 0; - for addr in addrs { - futs.push(request_one(None, addr, 0)); - initial_addrs += 1; - } - - let mut successes = 0; - let mut errors = 0; - - loop { - tokio::select! { - biased; - - r = node_rx.recv() => { - let Some((id, addr, depth)) = r else { break }; - futs.push(request_one(id, addr, depth)) - }, - f = futs.next() => { - let f = match f { - Some(f) => f, - None => { - // find_node recursion finished. - break; - } - }; - if f.is_ok() { - successes += 1; - } else { - errors += 1; - } - } - } - } - if successes == 0 { - return Err(Error::NoSuccessfulLookups { errors }); - } - debug!( - "finished, successes = {successes}, errors = {errors}, initial_addrs = {initial_addrs}" - ); - Ok(()) - } -} - -impl RecursiveRequest { - fn request_peers_forever( - self: &Arc, - mut node_rx: tokio::sync::mpsc::UnboundedReceiver<(Option, SocketAddr, usize)>, - is_v4: bool, - ) -> tokio::task::JoinHandle<()> { - let this = self.clone(); - spawn( - debug_span!(parent: None, "get_peers", is_v4, info_hash = format!("{:?}", self.info_hash)), - "get_peers", - async move { - let this = &this; - // Looper adds root nodes to the queue every 60 seconds. - let looper = { - async move { - let mut iteration = 0; - loop { - trace!("iteration {}", iteration); - let sleep = match this.get_peers_root(is_v4) { - Ok(0) => Duration::from_secs(1), - Ok(n) if n < 8 => REQUERY_INTERVAL / 8 * (n as u32), - Ok(_) => REQUERY_INTERVAL, - Err(e) => { - error!("dht: error in get_peers_root(): {e:#}"); - return Err::<(), crate::Error>(e); - } - }; - tokio::time::sleep(sleep).await; - iteration += 1; - } - } - }; - tokio::pin!(looper); - - let mut futs = FuturesUnordered::new(); - loop { - tokio::select! { - addr = node_rx.recv() => { - let Some((id, addr, depth)) = addr else { break Ok(()) }; - futs.push( - this.request_one(id, addr, depth) - .map_err(|e| debug!("error: {e:#}")) - .instrument(debug_span!("addr", addr=addr.to_string())) - ); - } - Some(_) = futs.next(), if !futs.is_empty() => {} - r = &mut looper => { - return r - } - } - } - }, - ) - } - - fn get_peers_root(&self, is_v4: bool) -> crate::Result { - let mut count = 0; - let table = if is_v4 { - &self.dht.routing_table_v4 - } else { - &self.dht.routing_table_v6 - }; - for (id, addr) in table - .read() - .sorted_by_distance_from(self.info_hash, now()) - .iter() - .map(|n| (n.id(), n.addr())) - .take(8) - { - count += 1; - self.node_tx - .send((Some(id), addr, 0)) - .ok() - .ok_or(Error::DhtDead)?; - } - - Ok(count) - } -} - -impl RecursiveRequest { - async fn request_one( - &self, - id: Option, - addr: SocketAddr, - depth: usize, - ) -> crate::Result<()> { - if let Some(id) = id { - self.callbacks.on_request_start(self, id, addr); - } - - let response = self - .dht - .request(self.request.clone(), addr) - .await - .inspect(|r| { - self.mark_node_responded(addr, r); - }); - if let Some(id) = id { - self.callbacks.on_request_end(self, id, addr, &response); - } - - let response = match self.dht.request(self.request.clone(), addr).await { - Ok(ResponseOrError::Response(r)) => r, - Ok(ResponseOrError::Error(e)) => { - debug!("error response: {e:?}"); - return Err(Error::ErrorResponse); - } - Err(e) => { - self.mark_node_error(addr); - return Err(e); - } - }; - - if let Some(peers) = response.values { - for peer in peers { - self.peer_tx.send(peer.0).ok().ok_or(Error::ReceiverDead)?; - } - } - - let node_it = response - .nodes - .iter() - .flat_map(|n| n.iter().map(|n| n.as_socketaddr())) - .chain( - response - .nodes6 - .iter() - .flat_map(|n| n.iter().map(|n| n.as_socketaddr())), - ) - .filter(|node| addr.is_ipv4() == node.addr.is_ipv4()); - - let now = now(); - - for node in node_it { - let should_request = self.should_request_node(node.id, node.addr, depth, now); - trace!( - "should_request={}, id={:?}, addr={}, depth={}/{}", - should_request, node.id, node.addr, depth, self.max_depth - ); - if should_request { - self.node_tx - .send((Some(node.id), node.addr, depth + 1)) - .ok() - .ok_or(Error::ReceiverDead)?; - } - } - Ok(()) - } - - fn mark_node_error(&self, addr: SocketAddr) -> bool { - self.useful_nodes - .write() - .iter_mut() - .find(|n| n.addr == addr) - .map(|n| { - n.errors_in_a_row += 1; - }) - .is_some() - } - - fn mark_node_responded(&self, addr: SocketAddr, response: &ResponseOrError) -> bool { - self.useful_nodes - .write() - .iter_mut() - .find(|n| n.addr == addr) - .map(|node| { - node.last_response = Some(now()); - node.errors_in_a_row = 0; - match response { - ResponseOrError::Response(r) => { - node.returned_peers = - r.values.as_ref().map(|c| !c.is_empty()).unwrap_or(false) - } - ResponseOrError::Error(_) => { - node.returned_peers = false; - } - } - }) - .is_some() - } - - fn should_request_node( - &self, - node_id: Id20, - addr: SocketAddr, - depth: usize, - now: Instant, - ) -> bool { - if depth >= self.max_depth { - return false; - } - - let mut closest_nodes = self.useful_nodes.write(); - - // If recently requested, ignore - if let Some(existing) = closest_nodes.iter_mut().find(|n| n.id == node_id) { - if now - existing.last_request > Duration::from_secs(60) { - existing.last_request = now; - return true; - } - return false; - } - - closest_nodes.push(MaybeUsefulNode { - id: node_id, - addr, - last_request: now, - last_response: None, - returned_peers: false, - errors_in_a_row: 0, - }); - - closest_nodes.sort_by_key(|n| { - let has_returned_peers_desc = Reverse(n.returned_peers); - let has_responded_desc = Reverse(n.last_response.is_some() as u8); - let distance = n.id.distance(&self.info_hash); - let freshest_response = n.last_response.map(|r| now - r).unwrap_or(Duration::MAX); - ( - has_returned_peers_desc, - has_responded_desc, - distance, - freshest_response, - ) - }); - if closest_nodes.len() > self.useful_nodes_limit { - let popped = closest_nodes.pop().unwrap(); - if popped.id == node_id { - return false; - } - } - true - } -} - -pub struct DhtState { - id: Id20, - next_transaction_id: AtomicU16, - - // Created requests: (transaction_id, addr) => Requests. - // If we get a response, it gets removed from here. - inflight_by_transaction_id: DashMap<(u16, SocketAddr), OutstandingRequest>, - - routing_table_v4: RwLock, - routing_table_v6: RwLock, - listen_addr: SocketAddr, - - // Sending requests to the worker. - rate_limiter: RateLimiter, - // This is to send raw messages - worker_sender: UnboundedSender, - - cancellation_token: CancellationToken, - - pub(crate) peer_store: PeerStore, -} - -impl DhtState { - fn new_internal( - id: Id20, - sender: UnboundedSender, - routing_table_v4: Option, - routing_table_v6: Option, - listen_addr: SocketAddr, - peer_store: PeerStore, - cancellation_token: CancellationToken, - ) -> Self { - let routing_table_v4 = routing_table_v4.unwrap_or_else(|| RoutingTable::new(id, None)); - let routing_table_v6 = routing_table_v6.unwrap_or_else(|| RoutingTable::new(id, None)); - Self { - id, - next_transaction_id: AtomicU16::new(0), - inflight_by_transaction_id: Default::default(), - routing_table_v4: RwLock::new(routing_table_v4), - routing_table_v6: RwLock::new(routing_table_v6), - worker_sender: sender, - listen_addr, - rate_limiter: make_rate_limiter(), - peer_store, - cancellation_token, - } - } - - async fn request(&self, request: Request, addr: SocketAddr) -> crate::Result { - self.rate_limiter.acquire_one().await; - let (tid, message) = self.create_request(request, addr); - let key = (tid, addr); - let (tx, rx) = tokio::sync::oneshot::channel(); - self.inflight_by_transaction_id - .insert(key, OutstandingRequest { done: tx }); - trace!("sending {message:?}"); - match self.worker_sender.send(WorkerSendRequest { - our_tid: Some(tid), - message, - addr, - }) { - Ok(_) => {} - Err(_) => { - self.inflight_by_transaction_id.remove(&key); - return Err(Error::DhtDead); - } - }; - match tokio::time::timeout(RESPONSE_TIMEOUT, rx).await { - Ok(Ok(r)) => r.map(|r| { - trace!("received {r:?}"); - r - }), - Ok(Err(_)) => { - self.inflight_by_transaction_id.remove(&key); - Err(Error::DhtDead) - } - Err(_) => { - self.inflight_by_transaction_id.remove(&key); - Err(Error::ResponseTimeout(RESPONSE_TIMEOUT)) - } - } - } - - fn create_request(&self, request: Request, addr: SocketAddr) -> (u16, Message) { - let transaction_id = self.next_transaction_id.fetch_add(1, Ordering::Relaxed); - let transaction_id_buf = [(transaction_id >> 8) as u8, (transaction_id & 0xff) as u8]; - - let want = if addr.is_ipv6() { - Some(Want::V6) - } else { - Some(Want::V4) - }; - - let message = match request { - Request::GetPeers(info_hash) => Message { - transaction_id: ByteBufOwned::from(transaction_id_buf.as_ref()), - version: None, - ip: None, - kind: MessageKind::GetPeersRequest(GetPeersRequest { - id: self.id, - info_hash, - want, - }), - }, - Request::FindNode(target) => Message { - transaction_id: ByteBufOwned::from(transaction_id_buf.as_ref()), - version: None, - ip: None, - kind: MessageKind::FindNodeRequest(FindNodeRequest { - id: self.id, - target, - want, - }), - }, - Request::Ping => Message { - transaction_id: ByteBufOwned::from(transaction_id_buf.as_ref()), - version: None, - ip: None, - kind: MessageKind::PingRequest(PingRequest { id: self.id }), - }, - Request::Announce { - info_hash, - token, - port, - } => Message { - kind: MessageKind::AnnouncePeer(AnnouncePeer { - id: self.id, - implied_port: 0, - info_hash, - port, - token, - }), - transaction_id: ByteBufOwned::from(transaction_id_buf.as_ref()), - version: None, - ip: None, - }, - }; - (transaction_id, message) - } - - fn generate_compact_nodes_both( - &self, - target: Id20, - want: Want, - ) -> ( - Option>, - Option>, - ) { - let now = now(); - match want { - Want::V4 => ( - Some(self.generate_compact_nodes(target, &self.routing_table_v4.read(), now)), - None, - ), - Want::V6 => ( - None, - Some(self.generate_compact_nodes(target, &self.routing_table_v6.read(), now)), - ), - Want::Both => ( - Some(self.generate_compact_nodes(target, &self.routing_table_v4.read(), now)), - Some(self.generate_compact_nodes(target, &self.routing_table_v6.read(), now)), - ), - Want::None => (None, None), - } - } - - fn get_table_for_addr(&self, addr: SocketAddr) -> &RwLock { - if addr.is_ipv4() { - &self.routing_table_v4 - } else { - &self.routing_table_v6 - } - } - - fn generate_compact_nodes( - &self, - target: Id20, - table: &RoutingTable, - now: Instant, - ) -> CompactNodeInfo - where - A: CompactSerialize + CompactSerializeFixedLen + FromSocketAddr, - Node: CompactSerialize + CompactSerializeFixedLen, - { - let it = table - .sorted_by_distance_from(target, now) - .into_iter() - .filter_map(|r| { - Some(Node { - id: r.id(), - addr: A::from_socket_addr(r.addr())?, - }) - }) - .take(8); - CompactNodeInfo::new_from_iter(it) - } - - fn on_received_message( - self: &Arc, - msg: Message, - addr: SocketAddr, - ) -> crate::Result<()> { - match &msg.kind { - // If it's a response to a request we made, find the request task, notify it with the response, - // and let it handle it. - MessageKind::Error(_) | MessageKind::Response(_) => { - let tid = msg - .get_our_transaction_id() - .ok_or(Error::BadTransactionId)?; - let request = match self - .inflight_by_transaction_id - .remove(&(tid, addr)) - .map(|(_, v)| v) - { - Some(req) => req, - None => { - trace!(?msg, "outstanding request not found"); - return Err(Error::RequestNotFound); - } - }; - - let response_or_error = match msg.kind { - MessageKind::Error(e) => ResponseOrError::Error(e), - MessageKind::Response(r) => ResponseOrError::Response(Box::new(r)), - _ => unreachable!(), - }; - match request.done.send(Ok(response_or_error)) { - Ok(_) => {} - Err(e) => { - debug!( - "received response, but the receiver task is closed: {:?}", - e - ); - } - } - return Ok(()); - } - _ => {} - }; - - trace!("received query from {addr}: {msg:?}"); - - match &msg.kind { - // Otherwise, respond to a query. - MessageKind::PingRequest(req) => { - let message = Message { - transaction_id: msg.transaction_id, - version: None, - ip: Some(addr), - kind: MessageKind::Response(bprotocol::Response { - id: self.id, - ..Default::default() - }), - }; - self.get_table_for_addr(addr) - .write() - .mark_last_query(&req.id, now()); - self.worker_sender - .send(WorkerSendRequest { - our_tid: None, - message, - addr, - }) - .ok() - .ok_or(Error::DhtDead)?; - Ok(()) - } - MessageKind::AnnouncePeer(ann) => { - self.get_table_for_addr(addr) - .write() - .mark_last_query(&ann.id, now()); - let added = self.peer_store.store_peer(ann, addr); - trace!("{addr}: added_peer={added}, announce={ann:?}"); - let message = Message { - transaction_id: msg.transaction_id, - version: None, - ip: Some(addr), - kind: MessageKind::Response(bprotocol::Response { - id: self.id, - ..Default::default() - }), - }; - self.worker_sender - .send(WorkerSendRequest { - our_tid: None, - message, - addr, - }) - .ok() - .ok_or(Error::DhtDead)?; - Ok(()) - } - MessageKind::GetPeersRequest(req) => { - let want = req - .want - .unwrap_or(if addr.is_ipv6() { Want::V6 } else { Want::V4 }); - let (nodes, nodes6) = self.generate_compact_nodes_both(req.info_hash, want); - let compact_peer_info = self.peer_store.get_for_info_hash(req.info_hash, want); - self.get_table_for_addr(addr) - .write() - .mark_last_query(&req.id, now()); - let message = Message { - transaction_id: msg.transaction_id, - version: None, - ip: Some(addr), - kind: MessageKind::Response(bprotocol::Response { - id: self.id, - nodes, - nodes6, - values: Some(compact_peer_info), - token: Some(ByteBufOwned::from( - &self.peer_store.gen_token_for(req.id, addr)[..], - )), - ..Default::default() - }), - }; - self.worker_sender - .send(WorkerSendRequest { - our_tid: None, - message, - addr, - }) - .ok() - .ok_or(Error::DhtDead)?; - Ok(()) - } - MessageKind::FindNodeRequest(req) => { - let want = req - .want - .unwrap_or(if addr.is_ipv6() { Want::V6 } else { Want::V4 }); - let (nodes, nodes6) = self.generate_compact_nodes_both(req.target, want); - self.get_table_for_addr(addr) - .write() - .mark_last_query(&req.id, now()); - let message = Message { - transaction_id: msg.transaction_id, - version: None, - ip: Some(addr), - kind: MessageKind::Response(bprotocol::Response { - id: self.id, - nodes, - nodes6, - ..Default::default() - }), - }; - self.worker_sender - .send(WorkerSendRequest { - our_tid: None, - message, - addr, - }) - .ok() - .ok_or(Error::DhtDead)?; - Ok(()) - } - MessageKind::Bep44GetRequest(req) => { - // BEP 44: respond to get requests for mutable/immutable items. - // Return closest nodes to target; actual item lookup from the store - // will be implemented when the full BEP 44 handler is wired up. - let (nodes, nodes6) = self.generate_compact_nodes_both(req.target, Want::Both); - self.get_table_for_addr(addr) - .write() - .mark_last_query(&req.id, now()); - let message = Message { - transaction_id: msg.transaction_id, - version: None, - ip: Some(addr), - kind: MessageKind::Response(bprotocol::Response { - id: self.id, - nodes, - nodes6, - token: Some(ByteBufOwned::from( - &self.peer_store.gen_token_for(req.id, addr)[..], - )), - ..Default::default() - }), - }; - self.worker_sender - .send(WorkerSendRequest { - our_tid: None, - message, - addr, - }) - .ok() - .ok_or(Error::DhtDead)?; - Ok(()) - } - MessageKind::Bep44PutRequest(req) => { - // BEP 44: respond to put requests for mutable items. - // Signature verification and item storage will be implemented - // when the full BEP 44 handler is wired up. - self.get_table_for_addr(addr) - .write() - .mark_last_query(&req.id, now()); - let message = Message { - transaction_id: msg.transaction_id, - version: None, - ip: Some(addr), - kind: MessageKind::Response(bprotocol::Response { - id: self.id, - ..Default::default() - }), - }; - self.worker_sender - .send(WorkerSendRequest { - our_tid: None, - message, - addr, - }) - .ok() - .ok_or(Error::DhtDead)?; - Ok(()) - } - _ => unreachable!(), - } - } - - pub fn get_stats(&self) -> DhtStats { - DhtStats { - id: self.id, - outstanding_requests: self.inflight_by_transaction_id.len(), - routing_table_size: self.routing_table_v4.read().len(), - routing_table_size_v6: self.routing_table_v6.read().len(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -enum Request { - GetPeers(Id20), - FindNode(Id20), - Announce { - info_hash: Id20, - token: ByteBufOwned, - port: u16, - }, - Ping, -} - -enum ResponseOrError { - Response(Box>), - Error(ErrorDescription), -} - -impl core::fmt::Debug for ResponseOrError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Response(r) => write!(f, "{r:?}"), - Self::Error(e) => write!(f, "{e:?}"), - } - } -} - -struct DhtWorker { - socket: UdpSocket, - dht: Arc, -} - -impl DhtWorker { - fn on_send_error(&self, tid: u16, addr: SocketAddr, err: crate::Error) { - if let Some((_, OutstandingRequest { done })) = - self.dht.inflight_by_transaction_id.remove(&(tid, addr)) - { - let _ = done.send(Err(err)).is_err(); - }; - } - - async fn bootstrap_hostname(&self, hostname: &str) -> crate::Result<()> { - let addrs = tokio::net::lookup_host(hostname) - .await - .map_err(|err| Error::lookup(hostname, err))? - .collect::>(); - let v4 = RecursiveRequest::find_node_for_routing_table( - self.dht.clone(), - self.dht.id, - addrs.iter().copied().filter(|a| a.is_ipv4()), - ) - .instrument(debug_span!("v4")); - - let v6 = RecursiveRequest::find_node_for_routing_table( - self.dht.clone(), - self.dht.id, - addrs.iter().copied().filter(|a| a.is_ipv6()), - ) - .instrument(debug_span!("v6")); - - let (v4, v6) = tokio::join!(v4, v6); - v4.or(v6) - } - - async fn bootstrap_hostname_with_backoff(&self, addr: &str) -> crate::Result<()> { - let backoff = ExponentialBuilder::new() - .with_max_delay(Duration::from_secs(60)) - .with_jitter() - .with_total_delay(Some(Duration::from_secs(86400))) - .without_max_times(); - - (|| self.bootstrap_hostname(addr)) - .retry(backoff) - .notify(|error, retry_in| { - warn!(?retry_in, ?addr, "error in bootstrap: {error:#}"); - }) - .instrument(debug_span!("bootstrap", hostname = addr)) - .await - } - - async fn bootstrap(&self, bootstrap_addrs: &[String]) -> crate::Result<()> { - let mut futs = FuturesUnordered::new(); - - for addr in bootstrap_addrs.iter() { - futs.push(self.bootstrap_hostname_with_backoff(addr)); - } - let mut successes = 0; - while let Some(resp) = futs.next().await { - if resp.is_ok() { - successes += 1 - } - } - if successes == 0 { - return Err(Error::BootstrapFailed); - } - Ok(()) - } - - async fn bucket_refresher(&self, is_v4: bool) -> crate::Result<()> { - let (tx, mut rx) = unbounded_channel(); - - let table = if is_v4 { - &self.dht.routing_table_v4 - } else { - &self.dht.routing_table_v6 - }; - - let mut futs = FuturesUnordered::new(); - let filler = async { - let mut interval = tokio::time::interval(INACTIVITY_TIMEOUT); - interval.tick().await; - let mut iteration = 0; - loop { - interval.tick().await; - let now = now(); - let mut found = 0; - - for bucket in table.read().iter_buckets() { - if now - bucket.leaf.last_refreshed < INACTIVITY_TIMEOUT { - continue; - } - found += 1; - let random_id = bucket.random_within(); - if tx.send(random_id).is_err() { - return; - } - } - trace!("iteration {}, refreshing {} buckets", iteration, found); - iteration += 1; - } - }; - - tokio::pin!(filler); - - loop { - tokio::select! { - _ = &mut filler => {}, - random_id = rx.recv() => { - let Some(random_id) = random_id else { break }; - let addrs = table - .read() - .sorted_by_distance_from(random_id, now()) - .iter() - .map(|n| n.addr()) - .take(8).collect::>(); - futs.push( - RecursiveRequest::find_node_for_routing_table( - self.dht.clone(), random_id, addrs.into_iter() - ).instrument(debug_span!("refresh_bucket")) - ); - }, - _ = futs.next(), if !futs.is_empty() => {}, - } - } - Ok(()) - } - - async fn pinger(&self, is_v4: bool) -> crate::Result<()> { - let table = if is_v4 { - &self.dht.routing_table_v4 - } else { - &self.dht.routing_table_v6 - }; - let mut futs = FuturesUnordered::new(); - let mut interval = tokio::time::interval(INACTIVITY_TIMEOUT / 4); - let (tx, mut rx) = unbounded_channel(); - let looper = async { - let mut iteration = 0; - loop { - interval.tick().await; - let mut found = 0; - let now = now(); - for node in table.read().iter() { - if matches!( - node.status(now), - NodeStatus::Questionable | NodeStatus::Unknown - ) { - found += 1; - if tx.send((node.id(), node.addr())).is_err() { - return; - } - } - } - trace!("iteration {}, pinging {} nodes", iteration, found); - iteration += 1; - } - }; - - tokio::pin!(looper); - - loop { - tokio::select! { - _ = &mut looper => {}, - r = rx.recv() => { - let Some((id, addr)) = r else { break }; - futs.push(async move { - table.write().mark_outgoing_request(&id, now()); - match self.dht.request(Request::Ping, addr).await { - Ok(_) => { - table.write().mark_response(&id, now()); - }, - Err(e) => { - table.write().mark_error(&id); - debug!("error: {e:#}"); - } - } - }.instrument(debug_span!("ping", addr=addr.to_string()))) - }, - _ = futs.next(), if !futs.is_empty() => {}, - } - } - Ok(()) - } - - async fn framer( - &self, - socket: &UdpSocket, - mut input_rx: UnboundedReceiver, - output_tx: Sender<(Message, SocketAddr)>, - ) -> crate::Result<()> { - let writer = async { - let mut buf = Vec::new(); - while let Some(WorkerSendRequest { - our_tid, - message, - addr, - }) = input_rx.recv().await - { - if our_tid.is_none() { - trace!("{}: sending {:?}", addr, &message); - } - buf.clear(); - bprotocol::serialize_message( - &mut buf, - message.transaction_id, - message.version, - message.ip, - message.kind, - ) - .unwrap(); - if let Err(e) = socket.send_to(&buf, addr).await { - debug!("error sending to {addr}: {e:#}"); - if let Some(tid) = our_tid { - self.on_send_error(tid, addr, Error::Send(e)); - } - } - } - Err(Error::DhtDead) - }; - let reader = async { - let mut buf = vec![0u8; 16384]; - loop { - let (size, addr) = socket.recv_from(&mut buf).await.map_err(Error::Recv)?; - match bprotocol::deserialize_message::(&buf[..size]) { - Ok(msg) => match output_tx.send((msg, addr)).await { - Ok(_) => {} - Err(_) => return Err(Error::DhtDead), - }, - Err(e) => debug!("{}: error deserializing incoming message: {}", addr, e), - } - } - }; - let result = tokio::select! { - err = writer => err, - err = reader => err, - }; - result - } - - async fn start( - self, - in_rx: UnboundedReceiver, - bootstrap_addrs: &[String], - ) -> crate::Result<()> { - let (out_tx, mut out_rx) = channel(1); - let framer = self - .framer(&self.socket, in_rx, out_tx) - .instrument(debug_span!("dht_framer")); - - let bootstrap = self.bootstrap(bootstrap_addrs); - let mut bootstrap_done = false; - - let response_reader = { - let this = &self; - async move { - while let Some((response, addr)) = out_rx.recv().await { - if let Err(e) = this.dht.on_received_message(response, addr) { - debug!("error in on_response, addr={:?}: {}", addr, e) - } - } - Err(Error::DhtDead) - } - } - .instrument(debug_span!("dht_response_reader")); - - let pinger_v4 = self.pinger(true).instrument(debug_span!("pinger_v4")); - let bucket_refresher_v4 = self - .bucket_refresher(true) - .instrument(debug_span!("bucket_refresher_v4")); - - let pinger_v6 = self.pinger(false).instrument(debug_span!("pinger_v6")); - let bucket_refresher_v6 = self - .bucket_refresher(false) - .instrument(debug_span!("bucket_refresher_v6")); - - let peer_store_gc = { - let dht = self.dht.clone(); - async move { - // Run GC every 5 minutes to clean up stale peers and tokens. - let mut interval = tokio::time::interval(Duration::from_secs(5 * 60)); - // Skip the first immediate tick so we don't GC right at startup. - interval.tick().await; - loop { - interval.tick().await; - dht.peer_store.garbage_collect_peers(); - } - } - } - .instrument(debug_span!("peer_store_gc")); - - tokio::pin!(framer); - tokio::pin!(bootstrap); - tokio::pin!(response_reader); - tokio::pin!(pinger_v4); - tokio::pin!(bucket_refresher_v4); - tokio::pin!(pinger_v6); - tokio::pin!(bucket_refresher_v6); - tokio::pin!(peer_store_gc); - - loop { - tokio::select! { - err = &mut framer => { - return Error::task_finished(&"framer", err); - }, - result = &mut bootstrap, if !bootstrap_done => { - bootstrap_done = true; - result?; - }, - err = &mut pinger_v4 => { - return Error::task_finished(&"pinger_v4", err); - }, - err = &mut bucket_refresher_v4 => { - return Error::task_finished(&"bucket_refresher_v4", err); - }, - err = &mut pinger_v6 => { - return Error::task_finished(&"pinger_v6", err); - }, - err = &mut bucket_refresher_v6 => { - return Error::task_finished(&"bucket_refresher_v6", err); - }, - err = &mut response_reader => { - return Error::task_finished(&"response_reader", err); - }, - _ = &mut peer_store_gc => { - // GC loop runs forever; if it somehow exits, just continue. - } - } - } - } -} - -#[derive(Default)] -pub struct DhtConfig<'a> { - pub peer_id: Option, - pub bootstrap_addrs: Option>, - pub routing_table: Option, - pub routing_table_v6: Option, - pub listen_addr: Option, - pub peer_store: Option, - pub cancellation_token: Option, - pub bind_device: Option<&'a BindDevice>, -} - -impl DhtState { - pub async fn new() -> crate::Result> { - Self::with_config(DhtConfig::default()).await - } - pub fn cancellation_token(&self) -> &CancellationToken { - &self.cancellation_token - } - - #[inline(never)] - pub fn with_config<'a>(mut config: DhtConfig<'a>) -> BoxFuture<'a, crate::Result>> { - async move { - let addr = config - .listen_addr - .unwrap_or((Ipv6Addr::UNSPECIFIED, 0).into()); - let socket = UdpSocket::bind_udp( - addr, - librqbit_dualstack_sockets::BindOpts { - request_dualstack: true, - reuseport: false, - device: config.bind_device, - }, - ) - .map_err(|e| Error::Bind(Box::new(e)))?; - - let listen_addr = socket.bind_addr(); - info!("DHT listening on {:?}", listen_addr); - - let peer_id = config - .peer_id - .unwrap_or_else(|| generate_azereus_style(*b"rQ", crate_version!())); - info!("starting up DHT with peer id {:?}", peer_id); - let bootstrap_addrs = config - .bootstrap_addrs - .unwrap_or_else(|| crate::DHT_BOOTSTRAP.iter().map(|v| v.to_string()).collect()); - - let token = config.cancellation_token.take().unwrap_or_default(); - - let (in_tx, in_rx) = unbounded_channel(); - let state = Arc::new(Self::new_internal( - peer_id, - in_tx, - config.routing_table, - config.routing_table_v6, - listen_addr, - config.peer_store.unwrap_or_else(|| PeerStore::new(peer_id)), - token, - )); - - spawn_with_cancel( - debug_span!("dht"), - "dht", - state.cancellation_token.clone(), - { - let state = state.clone(); - async move { - let worker = DhtWorker { socket, dht: state }; - worker.start(in_rx, &bootstrap_addrs).await - } - }, - ); - Ok(state) - } - .boxed() - } - - pub fn get_peers( - self: &Arc, - info_hash: Id20, - announce_port: Option, - ) -> RequestPeersStream { - RequestPeersStream::new(self.clone(), info_hash, announce_port) - } - - pub fn listen_addr(&self) -> SocketAddr { - self.listen_addr - } - - pub fn stats(&self) -> DhtStats { - self.get_stats() - } - - pub fn with_routing_tables R>(&self, f: F) -> R { - f(&self.routing_table_v4.read(), &self.routing_table_v6.read()) - } - - // pub fn clone_routing_table(&self) -> RoutingTable { - // self.routing_table.read().clone() - // } -} - -trait FromSocketAddr: Sized { - fn from_socket_addr(addr: SocketAddr) -> Option; -} - -impl FromSocketAddr for SocketAddrV4 { - fn from_socket_addr(addr: SocketAddr) -> Option { - match addr { - SocketAddr::V4(a) => Some(a), - _ => None, - } - } -} - -impl FromSocketAddr for SocketAddrV6 { - fn from_socket_addr(addr: SocketAddr) -> Option { - match addr { - SocketAddr::V6(a) => Some(a), - _ => None, - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/error.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/error.rs deleted file mode 100644 index 2a0078a1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/error.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::time::Duration; - -use bencode::SerializeError; - -#[derive(thiserror::Error, Debug)] -#[error("error looking up {hostname}: {err:#}")] -pub struct LookupError { - hostname: Box, - #[source] - err: std::io::Error, -} - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("error binding UDP socket: {0:#}")] - Bind(#[source] Box), - - #[error("bootstrapping failed")] - BootstrapFailed, - - #[error("{0} failed: {1:?}")] - TaskFailed(&'static &'static str, #[source] Box), - - #[error("{0} finished unexpectedly with no error")] - TaskQuit(&'static &'static str), - - #[error("no successful lookups, {errors} errors")] - NoSuccessfulLookups { errors: usize }, - - #[error("dht is dead")] - DhtDead, - - #[error("receiver is dead")] - ReceiverDead, - - #[error("error response")] - ErrorResponse, - - #[error("timeout at {0:?}")] - ResponseTimeout(Duration), - - #[error("bad transaction id")] - BadTransactionId, - - #[error("outstanding request not found")] - RequestNotFound, - - #[error(transparent)] - BootstrapLookup(Box), - - #[error("error sending: {0:#}")] - Send(#[source] std::io::Error), - #[error("error in recv: {0:#}")] - Recv(#[source] std::io::Error), - - #[error("bencode serialize error: {0:#}")] - Serialize(#[source] Box), -} - -impl From for Error { - fn from(value: SerializeError) -> Self { - Error::Serialize(Box::new(value)) - } -} - -impl Error { - pub fn lookup(hostname: &str, err: std::io::Error) -> Error { - Error::BootstrapLookup(Box::new(LookupError { - hostname: hostname.into(), - err, - })) - } - - pub fn task_finished(name: &'static &'static str, result: Result<()>) -> Result<()> { - match result { - Ok(()) => Err(Error::TaskQuit(name)), - Err(e) => Err(Error::TaskFailed(name, Box::new(e))), - } - } -} - -pub type Result = core::result::Result; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/lib.rs deleted file mode 100644 index cd42a862..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/lib.rs +++ /dev/null @@ -1,43 +0,0 @@ -pub mod bep44_crypto; -mod bprotocol; -mod dht; -mod error; -pub mod mutable_item_store; -mod peer_store; -mod persistence; -mod routing_table; -mod utils; - -use std::sync::Arc; -use std::time::Duration; - -pub use error::{Error, Result}; - -pub use crate::dht::DhtStats; -pub use crate::dht::{DhtConfig, DhtState, RequestPeersStream}; -pub use librtbit_core::hash_id::Id20; -pub use persistence::{PersistentDht, PersistentDhtConfig}; - -pub type Dht = Arc; - -// How long do we wait for a response from a DHT node. -pub(crate) const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); -// TODO: Not sure if we should re-query tbh. -pub(crate) const REQUERY_INTERVAL: Duration = Duration::from_secs(60); -// After how long we consider a routing table node questionable. -pub(crate) const INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); - -pub struct DhtBuilder {} - -impl DhtBuilder { - #[allow(clippy::new_ret_no_self)] - pub async fn new() -> crate::Result { - DhtState::new().await - } - - pub async fn with_config(config: DhtConfig<'_>) -> crate::Result { - DhtState::with_config(config).await - } -} - -pub static DHT_BOOTSTRAP: &[&str] = &["dht.transmissionbt.com:6881", "dht.libtorrent.org:25401"]; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/mutable_item_store.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/mutable_item_store.rs deleted file mode 100644 index 1d0cdbac..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/mutable_item_store.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Storage for BEP 44 mutable DHT items. -//! -//! Provides an in-memory store for mutable items indexed by their DHT target. -//! Items are stored with sequence number ordering: newer items (higher seq) -//! replace older ones. - -use std::time::Instant; - -use dashmap::DashMap; -use librtbit_core::hash_id::Id20; - -/// A stored BEP 44 mutable item. -#[derive(Debug, Clone)] -pub struct StoredMutableItem { - /// 32-byte Ed25519 public key. - pub k: [u8; 32], - /// 64-byte Ed25519 signature. - pub sig: [u8; 64], - /// Sequence number. - pub seq: i64, - /// The bencoded value (max 1000 bytes per BEP 44). - pub v: Vec, - /// Optional salt used in target computation. - pub salt: Option>, - /// When this item was last stored or updated. - pub last_updated: Instant, -} - -/// Thread-safe store for BEP 44 mutable DHT items. -/// -/// Items are keyed by their DHT target (SHA-1 of public_key [+ salt]). -/// The store enforces a maximum capacity and sequence number ordering. -pub struct MutableItemStore { - items: DashMap, - max_items: usize, -} - -impl MutableItemStore { - /// Create a new store with the given maximum capacity. - pub fn new(max_items: usize) -> Self { - Self { - items: DashMap::new(), - max_items, - } - } - - /// Store a mutable item. - /// - /// Returns `true` if the item was stored (either new or had a higher seq). - /// Returns `false` if an existing item has a seq >= the new item's seq. - /// - /// If the store is at capacity and the item is new, garbage collection - /// is performed first to evict the oldest item. - pub fn store(&self, target: Id20, item: StoredMutableItem) -> bool { - // Check if there is an existing item with a higher or equal seq. - if let Some(existing) = self.items.get(&target) - && existing.seq >= item.seq - { - return false; - } - - // If at capacity and this is a new item, evict the oldest. - if !self.items.contains_key(&target) && self.items.len() >= self.max_items { - self.evict_oldest(); - } - - self.items.insert(target, item); - true - } - - /// Retrieve a stored mutable item by its DHT target. - pub fn get(&self, target: &Id20) -> Option { - self.items.get(target).map(|entry| entry.clone()) - } - - /// Remove items that are oldest by `last_updated`, bringing the store - /// back under its maximum capacity. - pub fn garbage_collect(&self) { - while self.items.len() > self.max_items { - self.evict_oldest(); - } - } - - /// Returns the number of items currently stored. - pub fn len(&self) -> usize { - self.items.len() - } - - /// Returns true if the store is empty. - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - /// Evict the single oldest item (by `last_updated`). - fn evict_oldest(&self) { - let oldest = self - .items - .iter() - .min_by_key(|entry| entry.value().last_updated) - .map(|entry| *entry.key()); - - if let Some(key) = oldest { - self.items.remove(&key); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_item(seq: i64) -> StoredMutableItem { - StoredMutableItem { - k: [0xAA; 32], - sig: [0xBB; 64], - seq, - v: b"5:hello".to_vec(), - salt: None, - last_updated: Instant::now(), - } - } - - fn make_item_with_time(seq: i64, time: Instant) -> StoredMutableItem { - StoredMutableItem { - k: [0xAA; 32], - sig: [0xBB; 64], - seq, - v: b"5:hello".to_vec(), - salt: None, - last_updated: time, - } - } - - #[test] - fn test_store_and_get() { - let store = MutableItemStore::new(100); - let target = Id20::new([0x11; 20]); - let item = make_item(1); - - assert!(store.store(target, item)); - - let retrieved = store.get(&target).unwrap(); - assert_eq!(retrieved.seq, 1); - assert_eq!(retrieved.v, b"5:hello"); - } - - #[test] - fn test_get_nonexistent() { - let store = MutableItemStore::new(100); - let target = Id20::new([0x11; 20]); - assert!(store.get(&target).is_none()); - } - - #[test] - fn test_store_higher_seq_overwrites() { - let store = MutableItemStore::new(100); - let target = Id20::new([0x11; 20]); - - assert!(store.store(target, make_item(1))); - assert!(store.store(target, make_item(5))); - - let retrieved = store.get(&target).unwrap(); - assert_eq!(retrieved.seq, 5); - } - - #[test] - fn test_store_lower_seq_rejected() { - let store = MutableItemStore::new(100); - let target = Id20::new([0x11; 20]); - - assert!(store.store(target, make_item(5))); - assert!(!store.store(target, make_item(3))); - - let retrieved = store.get(&target).unwrap(); - assert_eq!(retrieved.seq, 5); - } - - #[test] - fn test_store_equal_seq_rejected() { - let store = MutableItemStore::new(100); - let target = Id20::new([0x11; 20]); - - assert!(store.store(target, make_item(5))); - assert!(!store.store(target, make_item(5))); - - assert_eq!(store.len(), 1); - } - - #[test] - fn test_max_capacity_eviction() { - let store = MutableItemStore::new(2); - let now = Instant::now(); - - let t1 = Id20::new([0x01; 20]); - let t2 = Id20::new([0x02; 20]); - let t3 = Id20::new([0x03; 20]); - - // Store two items; the first is oldest. - assert!(store.store(t1, make_item_with_time(1, now))); - assert!(store.store( - t2, - make_item_with_time(1, now + std::time::Duration::from_secs(1)) - )); - assert_eq!(store.len(), 2); - - // Storing a third item should evict the oldest (t1). - assert!(store.store( - t3, - make_item_with_time(1, now + std::time::Duration::from_secs(2)) - )); - assert_eq!(store.len(), 2); - assert!(store.get(&t1).is_none()); - assert!(store.get(&t2).is_some()); - assert!(store.get(&t3).is_some()); - } - - #[test] - fn test_update_existing_does_not_evict() { - let store = MutableItemStore::new(2); - - let t1 = Id20::new([0x01; 20]); - let t2 = Id20::new([0x02; 20]); - - assert!(store.store(t1, make_item(1))); - assert!(store.store(t2, make_item(1))); - assert_eq!(store.len(), 2); - - // Updating an existing item (higher seq) should not trigger eviction. - assert!(store.store(t1, make_item(2))); - assert_eq!(store.len(), 2); - assert_eq!(store.get(&t1).unwrap().seq, 2); - assert!(store.get(&t2).is_some()); - } - - #[test] - fn test_garbage_collect() { - let store = MutableItemStore::new(3); - let now = Instant::now(); - - for i in 0..5u8 { - let target = Id20::new([i; 20]); - // Bypass max_items by inserting directly for this test. - store.items.insert( - target, - make_item_with_time(i as i64, now + std::time::Duration::from_secs(i as u64)), - ); - } - - assert_eq!(store.len(), 5); - - store.garbage_collect(); - - // Should be back to max_items (3), with the 2 oldest removed. - assert_eq!(store.len(), 3); - // The oldest items (i=0, i=1) should be evicted. - assert!(store.get(&Id20::new([0; 20])).is_none()); - assert!(store.get(&Id20::new([1; 20])).is_none()); - // The newer items should remain. - assert!(store.get(&Id20::new([2; 20])).is_some()); - assert!(store.get(&Id20::new([3; 20])).is_some()); - assert!(store.get(&Id20::new([4; 20])).is_some()); - } - - #[test] - fn test_len_and_is_empty() { - let store = MutableItemStore::new(10); - assert!(store.is_empty()); - assert_eq!(store.len(), 0); - - let target = Id20::new([0x11; 20]); - store.store(target, make_item(1)); - assert!(!store.is_empty()); - assert_eq!(store.len(), 1); - } - - #[test] - fn test_store_with_salt() { - let store = MutableItemStore::new(100); - let target = Id20::new([0x11; 20]); - - let mut item = make_item(1); - item.salt = Some(b"my-salt".to_vec()); - assert!(store.store(target, item)); - - let retrieved = store.get(&target).unwrap(); - assert_eq!(retrieved.salt.as_deref(), Some(b"my-salt".as_ref())); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/peer_store.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/peer_store.rs deleted file mode 100644 index a42a5f67..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/peer_store.rs +++ /dev/null @@ -1,819 +0,0 @@ -use std::{collections::VecDeque, net::SocketAddr, str::FromStr, sync::atomic::AtomicU32}; - -use bencode::ByteBufOwned; -use chrono::{DateTime, TimeDelta, Utc}; -use librtbit_core::{compact_ip::CompactSocketAddr, hash_id::Id20}; -use parking_lot::RwLock; -use rand::RngCore; -use serde::{ - Deserialize, Serialize, - ser::{SerializeMap, SerializeStruct}, -}; -use tracing::{debug, trace}; - -use crate::bprotocol::{AnnouncePeer, Want}; - -#[derive(Serialize, Deserialize)] -struct StoredToken { - token: [u8; 4], - #[serde(serialize_with = "crate::utils::serialize_id20")] - node_id: Id20, - addr: SocketAddr, - #[serde(default = "Utc::now")] - time: DateTime, -} - -#[derive(Serialize, Deserialize)] -struct StoredPeer { - addr: SocketAddr, - time: DateTime, -} - -pub struct PeerStore { - self_id: Id20, - max_remembered_tokens: u32, - max_remembered_peers: u32, - max_distance: Id20, - tokens: RwLock>, - peers: dashmap::DashMap>, - peers_len: AtomicU32, -} - -impl Serialize for PeerStore { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - struct SerializePeers<'a> { - peers: &'a dashmap::DashMap>, - } - - impl Serialize for SerializePeers<'_> { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut m = serializer.serialize_map(None)?; - for entry in self.peers.iter() { - m.serialize_entry(&entry.key().as_string(), &entry.value())?; - } - m.end() - } - } - - let mut s = serializer.serialize_struct("PeerStore", 7)?; - s.serialize_field("self_id", &self.self_id.as_string())?; - s.serialize_field("max_remembered_tokens", &self.max_remembered_tokens)?; - s.serialize_field("max_remembered_peers", &self.max_remembered_peers)?; - s.serialize_field("max_distance", &self.max_distance.as_string())?; - s.serialize_field("tokens", &*self.tokens.read())?; - s.serialize_field("peers", &SerializePeers { peers: &self.peers })?; - s.serialize_field( - "peers_len", - &self.peers_len.load(std::sync::atomic::Ordering::SeqCst), - )?; - s.end() - } -} - -impl<'de> Deserialize<'de> for PeerStore { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct Tmp { - self_id: Id20, - max_remembered_tokens: u32, - max_remembered_peers: u32, - max_distance: Id20, - tokens: VecDeque, - peers: dashmap::DashMap>, - } - - Tmp::deserialize(deserializer).map(|tmp| Self { - self_id: tmp.self_id, - max_remembered_tokens: tmp.max_remembered_tokens, - max_remembered_peers: tmp.max_remembered_peers, - max_distance: tmp.max_distance, - tokens: RwLock::new(tmp.tokens), - peers_len: AtomicU32::new(tmp.peers.iter().map(|e| e.value().len() as u32).sum()), - peers: tmp.peers, - }) - } -} - -impl PeerStore { - pub fn new(self_id: Id20) -> Self { - Self { - self_id, - max_remembered_tokens: 1000, - max_remembered_peers: 1000, - max_distance: Id20::from_str("00000fffffffffffffffffffffffffffffffffff").unwrap(), - tokens: RwLock::new(VecDeque::new()), - peers: dashmap::DashMap::new(), - peers_len: AtomicU32::new(0), - } - } - - pub fn gen_token_for(&self, node_id: Id20, addr: SocketAddr) -> [u8; 4] { - let mut token = [0u8; 4]; - rand::rng().fill_bytes(&mut token); - let mut tokens = self.tokens.write(); - tokens.push_back(StoredToken { - token, - addr, - node_id, - time: Utc::now(), - }); - if tokens.len() > self.max_remembered_tokens as usize { - tokens.pop_front(); - } - token - } - - pub fn store_peer(&self, announce: &AnnouncePeer, mut addr: SocketAddr) -> bool { - // If the info_hash in announce is too far away from us, don't store it. - // If the token doesn't match, don't store it. - // If we are out of capacity, don't store it. - // Otherwise, store it. - if announce.info_hash.distance(&self.self_id) > self.max_distance { - trace!("peer store: info_hash too far to store"); - return false; - } - if !self.tokens.read().iter().any(|t| { - t.token[..] == announce.token.as_ref()[..] && t.addr == addr && t.node_id == announce.id - }) { - trace!("peer store: can't find this token / addr combination"); - return false; - } - - if announce.implied_port == 0 { - addr.set_port(announce.port); - } - - use dashmap::mapref::entry::Entry; - let peers_entry = self.peers.entry(announce.info_hash); - let peers_len = self.peers_len.load(std::sync::atomic::Ordering::SeqCst); - match peers_entry { - Entry::Occupied(mut occ) => { - if let Some(s) = occ.get_mut().iter_mut().find(|s| s.addr == addr) { - s.time = Utc::now(); - return true; - } - if peers_len >= self.max_remembered_peers { - trace!("peer store: out of capacity"); - return false; - } - occ.get_mut().push(StoredPeer { - addr, - time: Utc::now(), - }); - } - Entry::Vacant(vac) => { - if peers_len >= self.max_remembered_peers { - trace!("peer store: out of capacity"); - return false; - } - vac.insert(vec![StoredPeer { - addr, - time: Utc::now(), - }]); - } - } - - self.peers_len - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - true - } - - pub fn get_for_info_hash(&self, info_hash: Id20, want: Want) -> Vec { - if let Some(stored_peers) = self.peers.get(&info_hash) { - return stored_peers - .iter() - .filter(|p| { - matches!( - (p.addr, want), - (SocketAddr::V6(..), Want::V6 | Want::Both) - | (SocketAddr::V4(..), Want::V4 | Want::Both) - ) - }) - .map(|p| p.addr.into()) - .collect(); - } - Vec::new() - } - - /// Maximum number of peers to keep per info_hash. - const MAX_PEERS_PER_INFO_HASH: usize = 100; - - /// Peers older than this are considered stale and removed. - const PEER_TTL: TimeDelta = TimeDelta::minutes(15); - - /// Tokens older than this are removed. - const TOKEN_TTL: TimeDelta = TimeDelta::minutes(10); - - /// Create a PeerStore with custom limits for testing. - #[cfg(test)] - fn with_limits(self_id: Id20, max_tokens: u32, max_peers: u32) -> Self { - Self { - self_id, - max_remembered_tokens: max_tokens, - max_remembered_peers: max_peers, - max_distance: Id20::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap(), - tokens: RwLock::new(VecDeque::new()), - peers: dashmap::DashMap::new(), - peers_len: AtomicU32::new(0), - } - } - - /// Run garbage collection on the peer store. - /// - /// This performs: - /// 1. Token cleanup: removes tokens older than 10 minutes. - /// 2. Peer TTL: removes peers not seen in the last 15 minutes. - /// 3. Per-info_hash cap: keeps only the most recent peers (up to 100) per hash. - /// 4. Global cap: if still over `max_remembered_peers`, evicts oldest peers first. - pub fn garbage_collect_peers(&self) { - let now = Utc::now(); - - // 1. Clean up expired tokens. - { - let mut tokens = self.tokens.write(); - let token_cutoff = now - Self::TOKEN_TTL; - let before = tokens.len(); - tokens.retain(|t| t.time > token_cutoff); - let removed = before - tokens.len(); - if removed > 0 { - debug!("peer store GC: removed {removed} expired tokens"); - } - } - - // 2. Remove stale peers (older than PEER_TTL) and enforce per-info_hash cap. - let peer_cutoff = now - Self::PEER_TTL; - let mut total_removed: u32 = 0; - let mut empty_hashes = Vec::new(); - - for mut entry in self.peers.iter_mut() { - let info_hash = *entry.key(); - let peers = entry.value_mut(); - let before = peers.len(); - - // Remove peers older than the TTL. - peers.retain(|p| p.time > peer_cutoff); - - // Enforce per-info_hash cap: keep only the most recent peers. - if peers.len() > Self::MAX_PEERS_PER_INFO_HASH { - peers.sort_by(|a, b| b.time.cmp(&a.time)); - peers.truncate(Self::MAX_PEERS_PER_INFO_HASH); - } - - let removed = before - peers.len(); - total_removed += removed as u32; - - if peers.is_empty() { - empty_hashes.push(info_hash); - } - } - - // Remove empty info_hash entries. - for hash in &empty_hashes { - self.peers.remove(hash); - } - - // 3. Enforce global cap by evicting oldest peers first. - // Recompute the actual count after TTL/per-hash cleanup. - let actual_count: u32 = self.peers.iter().map(|e| e.value().len() as u32).sum(); - let max = self.max_remembered_peers; - - if actual_count > max { - let to_evict = actual_count - max; - let mut evicted = 0u32; - - // Collect (info_hash, oldest_time) pairs so we can evict from the - // entries with the oldest peers first. - let mut entries_by_oldest: Vec<(Id20, DateTime)> = self - .peers - .iter() - .filter_map(|entry| { - entry - .value() - .iter() - .map(|p| p.time) - .min() - .map(|oldest| (*entry.key(), oldest)) - }) - .collect(); - entries_by_oldest.sort_by_key(|(_hash, oldest)| *oldest); - - for (hash, _) in entries_by_oldest { - if evicted >= to_evict { - break; - } - if let Some(mut entry) = self.peers.get_mut(&hash) { - let peers = entry.value_mut(); - peers.sort_by(|a, b| a.time.cmp(&b.time)); - while !peers.is_empty() && evicted < to_evict { - peers.remove(0); - evicted += 1; - } - if peers.is_empty() { - drop(entry); - self.peers.remove(&hash); - } - } - } - total_removed += evicted; - } - - // Update the atomic counter to the true count. - let final_count: u32 = self.peers.iter().map(|e| e.value().len() as u32).sum(); - self.peers_len - .store(final_count, std::sync::atomic::Ordering::SeqCst); - - if total_removed > 0 { - debug!( - "peer store GC: removed {total_removed} peers, {remaining} remaining, {hashes} info_hashes", - remaining = final_count, - hashes = self.peers.len(), - ); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; - - /// Helper: create a deterministic Id20 from a single byte prefix. - fn id_from_byte(b: u8) -> Id20 { - let mut data = [0u8; 20]; - data[0] = b; - Id20::new(data) - } - - /// Helper: create a deterministic IPv4 SocketAddr. - fn addr_v4(port: u16) -> SocketAddr { - SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), port)) - } - - /// Helper: create a deterministic IPv6 SocketAddr. - fn addr_v6(port: u16) -> SocketAddr { - SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::LOCALHOST, port, 0, 0)) - } - - /// Helper: build an AnnouncePeer message. - fn make_announce( - node_id: Id20, - info_hash: Id20, - token: &[u8], - port: u16, - implied_port: u8, - ) -> AnnouncePeer { - AnnouncePeer { - id: node_id, - implied_port, - info_hash, - port, - token: ByteBufOwned::from(token), - } - } - - // ----------------------------------------------------------------------- - // test_new_peer_store - // ----------------------------------------------------------------------- - #[test] - fn test_new_peer_store() { - let id = id_from_byte(0x42); - let store = PeerStore::new(id); - assert_eq!(store.self_id, id); - assert_eq!(store.max_remembered_tokens, 1000); - assert_eq!(store.max_remembered_peers, 1000); - assert!(store.tokens.read().is_empty()); - assert!(store.peers.is_empty()); - assert_eq!(store.peers_len.load(std::sync::atomic::Ordering::SeqCst), 0); - } - - // ----------------------------------------------------------------------- - // test_gen_token_and_store_peer - // ----------------------------------------------------------------------- - #[test] - fn test_gen_token_and_store_peer() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let node_id = id_from_byte(0x01); - let info_hash = id_from_byte(0x02); - let addr = addr_v4(6881); - - // Generate a token for this node. - let token = store.gen_token_for(node_id, addr); - assert_eq!(store.tokens.read().len(), 1); - - // Store a peer using that token. - let announce = make_announce(node_id, info_hash, &token, 6882, 0); - assert!(store.store_peer(&announce, addr)); - - // Verify the peer was stored. - let peers = store.get_for_info_hash(info_hash, Want::Both); - assert_eq!(peers.len(), 1); - // The port should be from the announce (implied_port=0 means use announce.port). - assert_eq!(peers[0].0.port(), 6882); - } - - // ----------------------------------------------------------------------- - // test_store_peer_rejects_invalid_token - // ----------------------------------------------------------------------- - #[test] - fn test_store_peer_rejects_invalid_token() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let node_id = id_from_byte(0x01); - let info_hash = id_from_byte(0x02); - let addr = addr_v4(6881); - - // Generate a valid token but use a different one. - let _valid_token = store.gen_token_for(node_id, addr); - let bad_token = [0xDE, 0xAD, 0xBE, 0xEF]; - - let announce = make_announce(node_id, info_hash, &bad_token, 6882, 0); - assert!(!store.store_peer(&announce, addr)); - - // Nothing should be stored. - let peers = store.get_for_info_hash(info_hash, Want::Both); - assert!(peers.is_empty()); - } - - // ----------------------------------------------------------------------- - // test_store_peer_rejects_far_info_hash - // ----------------------------------------------------------------------- - #[test] - fn test_store_peer_rejects_far_info_hash() { - let self_id = id_from_byte(0x00); - // Use the default PeerStore (which has max_distance = 00000fffff...). - let store = PeerStore::new(self_id); - - let node_id = id_from_byte(0x01); - // info_hash that is very far from self_id (high bits set). - let info_hash = id_from_byte(0xFF); - let addr = addr_v4(6881); - - let token = store.gen_token_for(node_id, addr); - let announce = make_announce(node_id, info_hash, &token, 6882, 0); - // Should be rejected because distance(info_hash, self_id) > max_distance. - assert!(!store.store_peer(&announce, addr)); - } - - // ----------------------------------------------------------------------- - // test_store_peer_updates_existing - // ----------------------------------------------------------------------- - #[test] - fn test_store_peer_updates_existing() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let node_id = id_from_byte(0x01); - let info_hash = id_from_byte(0x02); - let addr = addr_v4(6881); - - let token = store.gen_token_for(node_id, addr); - - // Store a peer (implied_port=1 means use the addr's port, not announce.port). - let announce = make_announce(node_id, info_hash, &token, 6882, 1); - assert!(store.store_peer(&announce, addr)); - assert_eq!(store.peers_len.load(std::sync::atomic::Ordering::SeqCst), 1); - - // Store the same peer again — should update, not add a new one. - assert!(store.store_peer(&announce, addr)); - assert_eq!(store.peers_len.load(std::sync::atomic::Ordering::SeqCst), 1); - - let peers = store.get_for_info_hash(info_hash, Want::Both); - assert_eq!(peers.len(), 1); - } - - // ----------------------------------------------------------------------- - // test_store_peer_capacity_limit - // ----------------------------------------------------------------------- - #[test] - fn test_store_peer_capacity_limit() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 1000, 3); - - let info_hash = id_from_byte(0x02); - - // Store 3 peers (the max). - for i in 0u16..3 { - let node_id = { - let mut d = [0u8; 20]; - d[0] = (i + 10) as u8; - Id20::new(d) - }; - let addr = addr_v4(7000 + i); - let token = store.gen_token_for(node_id, addr); - let announce = make_announce(node_id, info_hash, &token, 8000 + i, 0); - assert!(store.store_peer(&announce, addr)); - } - assert_eq!(store.peers_len.load(std::sync::atomic::Ordering::SeqCst), 3); - - // The 4th peer should be rejected. - let node_id = id_from_byte(0x20); - let addr = addr_v4(9000); - let token = store.gen_token_for(node_id, addr); - let announce = make_announce(node_id, info_hash, &token, 9001, 0); - assert!(!store.store_peer(&announce, addr)); - - assert_eq!(store.peers_len.load(std::sync::atomic::Ordering::SeqCst), 3); - } - - // ----------------------------------------------------------------------- - // test_get_for_info_hash - // ----------------------------------------------------------------------- - #[test] - fn test_get_for_info_hash() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let info_hash_a = id_from_byte(0x10); - let info_hash_b = id_from_byte(0x20); - - // Add a peer to info_hash_a. - let node = id_from_byte(0x01); - let addr = addr_v4(5000); - let token = store.gen_token_for(node, addr); - let ann = make_announce(node, info_hash_a, &token, 5001, 0); - assert!(store.store_peer(&ann, addr)); - - // info_hash_a should have 1 peer. - assert_eq!(store.get_for_info_hash(info_hash_a, Want::Both).len(), 1); - // info_hash_b should have 0 peers. - assert!(store.get_for_info_hash(info_hash_b, Want::Both).is_empty()); - } - - // ----------------------------------------------------------------------- - // test_get_for_info_hash_want_filter - // ----------------------------------------------------------------------- - #[test] - fn test_get_for_info_hash_want_filter() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let info_hash = id_from_byte(0x05); - - // Add a v4 peer. - let node_v4 = id_from_byte(0x01); - let addr_v4_ = addr_v4(5000); - let token_v4 = store.gen_token_for(node_v4, addr_v4_); - let ann_v4 = make_announce(node_v4, info_hash, &token_v4, 5001, 0); - assert!(store.store_peer(&ann_v4, addr_v4_)); - - // Add a v6 peer. - let node_v6 = id_from_byte(0x02); - let addr_v6_ = addr_v6(6000); - let token_v6 = store.gen_token_for(node_v6, addr_v6_); - let ann_v6 = make_announce(node_v6, info_hash, &token_v6, 6001, 0); - assert!(store.store_peer(&ann_v6, addr_v6_)); - - // Want::Both should return 2. - assert_eq!(store.get_for_info_hash(info_hash, Want::Both).len(), 2); - // Want::V4 should return only the v4 peer. - let v4_peers = store.get_for_info_hash(info_hash, Want::V4); - assert_eq!(v4_peers.len(), 1); - assert!(v4_peers[0].0.is_ipv4()); - // Want::V6 should return only the v6 peer. - let v6_peers = store.get_for_info_hash(info_hash, Want::V6); - assert_eq!(v6_peers.len(), 1); - assert!(v6_peers[0].0.is_ipv6()); - } - - // ----------------------------------------------------------------------- - // test_garbage_collect_removes_stale_peers - // ----------------------------------------------------------------------- - #[test] - fn test_garbage_collect_removes_stale_peers() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let info_hash = id_from_byte(0x05); - - // Manually insert a peer with a stale timestamp. - let stale_time = Utc::now() - TimeDelta::minutes(20); - store.peers.insert( - info_hash, - vec![StoredPeer { - addr: addr_v4(5000), - time: stale_time, - }], - ); - store - .peers_len - .store(1, std::sync::atomic::Ordering::SeqCst); - - assert_eq!(store.get_for_info_hash(info_hash, Want::Both).len(), 1); - - // GC should remove this stale peer. - store.garbage_collect_peers(); - - assert!(store.get_for_info_hash(info_hash, Want::Both).is_empty()); - assert_eq!(store.peers_len.load(std::sync::atomic::Ordering::SeqCst), 0); - } - - // ----------------------------------------------------------------------- - // test_garbage_collect_removes_stale_tokens - // ----------------------------------------------------------------------- - #[test] - fn test_garbage_collect_removes_stale_tokens() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - // Manually insert a stale token. - { - let mut tokens = store.tokens.write(); - tokens.push_back(StoredToken { - token: [1, 2, 3, 4], - node_id: id_from_byte(0x01), - addr: addr_v4(5000), - time: Utc::now() - TimeDelta::minutes(15), // older than TOKEN_TTL (10 min) - }); - } - assert_eq!(store.tokens.read().len(), 1); - - // Also insert a fresh token. - let _ = store.gen_token_for(id_from_byte(0x02), addr_v4(5001)); - assert_eq!(store.tokens.read().len(), 2); - - store.garbage_collect_peers(); - - // The stale token should be removed, the fresh one kept. - assert_eq!(store.tokens.read().len(), 1); - } - - // ----------------------------------------------------------------------- - // test_garbage_collect_per_hash_cap - // ----------------------------------------------------------------------- - #[test] - fn test_garbage_collect_per_hash_cap() { - let self_id = id_from_byte(0x00); - // Use large global cap so it doesn't interfere. - let store = PeerStore::with_limits(self_id, 1000, 10000); - - let info_hash = id_from_byte(0x05); - - // Insert 150 peers for a single info_hash (more than MAX_PEERS_PER_INFO_HASH = 100). - let mut peers = Vec::new(); - for i in 0u16..150 { - peers.push(StoredPeer { - addr: addr_v4(5000 + i), - time: Utc::now() - TimeDelta::seconds(i as i64), // newer peers first - }); - } - store.peers.insert(info_hash, peers); - store - .peers_len - .store(150, std::sync::atomic::Ordering::SeqCst); - - store.garbage_collect_peers(); - - // Should be truncated to MAX_PEERS_PER_INFO_HASH (100). - let remaining = store.peers.get(&info_hash).unwrap().len(); - assert_eq!(remaining, PeerStore::MAX_PEERS_PER_INFO_HASH); - assert_eq!( - store.peers_len.load(std::sync::atomic::Ordering::SeqCst), - PeerStore::MAX_PEERS_PER_INFO_HASH as u32 - ); - } - - // ----------------------------------------------------------------------- - // test_garbage_collect_global_cap - // ----------------------------------------------------------------------- - #[test] - fn test_garbage_collect_global_cap() { - let self_id = id_from_byte(0x00); - // Global cap = 5, but insert more. - let store = PeerStore::with_limits(self_id, 1000, 5); - - // Insert peers across different info_hashes. - for i in 0u8..10 { - let info_hash = { - let mut d = [0u8; 20]; - d[0] = i; - Id20::new(d) - }; - store.peers.insert( - info_hash, - vec![StoredPeer { - addr: addr_v4(5000 + i as u16), - time: Utc::now() - TimeDelta::seconds(i as i64), - }], - ); - } - store - .peers_len - .store(10, std::sync::atomic::Ordering::SeqCst); - - store.garbage_collect_peers(); - - let final_count = store.peers_len.load(std::sync::atomic::Ordering::SeqCst); - assert!( - final_count <= 5, - "expected at most 5 peers after GC, got {final_count}" - ); - } - - // ----------------------------------------------------------------------- - // test_garbage_collect_empty_hashes_removed - // ----------------------------------------------------------------------- - #[test] - fn test_garbage_collect_empty_hashes_removed() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let info_hash = id_from_byte(0x05); - - // Insert a single stale peer. - store.peers.insert( - info_hash, - vec![StoredPeer { - addr: addr_v4(5000), - time: Utc::now() - TimeDelta::minutes(20), - }], - ); - store - .peers_len - .store(1, std::sync::atomic::Ordering::SeqCst); - - assert!(store.peers.contains_key(&info_hash)); - - store.garbage_collect_peers(); - - // The info_hash entry itself should be removed. - assert!(!store.peers.contains_key(&info_hash)); - } - - // ----------------------------------------------------------------------- - // test_implied_port_uses_sender_addr_port - // ----------------------------------------------------------------------- - #[test] - fn test_implied_port_uses_sender_addr_port() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let node_id = id_from_byte(0x01); - let info_hash = id_from_byte(0x02); - let addr = addr_v4(9999); - - let token = store.gen_token_for(node_id, addr); - - // implied_port=1 means the peer's listen port is the same as the UDP source port. - let announce = make_announce(node_id, info_hash, &token, 1234, 1); - assert!(store.store_peer(&announce, addr)); - - let peers = store.get_for_info_hash(info_hash, Want::Both); - assert_eq!(peers.len(), 1); - // Should use the sender's port (9999), not the announce port (1234). - assert_eq!(peers[0].0.port(), 9999); - } - - // ----------------------------------------------------------------------- - // test_token_requires_matching_addr - // ----------------------------------------------------------------------- - #[test] - fn test_token_requires_matching_addr() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let node_id = id_from_byte(0x01); - let info_hash = id_from_byte(0x02); - let addr_a = addr_v4(6881); - let addr_b = addr_v4(6882); - - // Token generated for addr_a. - let token = store.gen_token_for(node_id, addr_a); - - // Try to use it from addr_b — should fail. - let announce = make_announce(node_id, info_hash, &token, 6882, 0); - assert!(!store.store_peer(&announce, addr_b)); - } - - // ----------------------------------------------------------------------- - // test_token_requires_matching_node_id - // ----------------------------------------------------------------------- - #[test] - fn test_token_requires_matching_node_id() { - let self_id = id_from_byte(0x00); - let store = PeerStore::with_limits(self_id, 100, 100); - - let node_id_a = id_from_byte(0x01); - let node_id_b = id_from_byte(0x02); - let info_hash = id_from_byte(0x03); - let addr = addr_v4(6881); - - // Token generated for node_id_a. - let token = store.gen_token_for(node_id_a, addr); - - // Announce claims to be node_id_b — should fail. - let announce = make_announce(node_id_b, info_hash, &token, 6882, 0); - assert!(!store.store_peer(&announce, addr)); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/persistence.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/persistence.rs deleted file mode 100644 index 73b38bb0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/persistence.rs +++ /dev/null @@ -1,208 +0,0 @@ -// TODO: this now stores only the routing table, but we also need AT LEAST the same socket address... - -use anyhow::Context; -use futures::FutureExt; -use futures::future::BoxFuture; -use librqbit_dualstack_sockets::BindDevice; -use librtbit_core::directories::get_configuration_directory; -use librtbit_core::spawn_utils::spawn_with_cancel; -use serde::{Deserialize, Serialize}; -use std::fs::OpenOptions; -use std::io::{BufReader, BufWriter}; -use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use tokio_util::sync::CancellationToken; - -use tracing::{debug_span, error, info, trace, warn}; - -use crate::peer_store::PeerStore; -use crate::routing_table::RoutingTable; -use crate::{Dht, DhtConfig, DhtState}; - -#[derive(Default)] -pub struct PersistentDhtConfig { - pub dump_interval: Option, - pub config_filename: Option, - pub port: Option, - pub ipv4_only: bool, -} - -#[derive(Serialize, Deserialize)] -struct DhtSerialize { - addr: SocketAddr, - table: Table, - // option for backwards compat - table_v6: Option, - peer_store: Option, -} - -pub struct PersistentDht { - // config_filename: PathBuf, -} - -fn dump_dht(dht: &Dht, filename: &Path, tempfile_name: &Path) -> anyhow::Result<()> { - let file = OpenOptions::new() - .truncate(true) - .create(true) - .write(true) - .open(tempfile_name) - .with_context(|| format!("error opening {tempfile_name:?}"))?; - let mut file = BufWriter::new(file); - - let addr = dht.listen_addr(); - match dht.with_routing_tables(|v4, v6| { - serde_json::to_writer( - &mut file, - &DhtSerialize { - addr, - table: v4, - table_v6: Some(v6), - peer_store: Some(&dht.peer_store), - }, - ) - }) { - Ok(_) => { - trace!("dumped DHT to {:?}", &tempfile_name); - } - Err(e) => { - return Err(e).with_context(|| { - format!("error serializing DHT routing table to {tempfile_name:?}") - }); - } - } - - std::fs::rename(tempfile_name, filename) - .with_context(|| format!("error renaming {tempfile_name:?} to {filename:?}")) -} - -impl PersistentDht { - pub fn default_persistence_filename() -> anyhow::Result { - let dirs = get_configuration_directory("dht")?; - let path = dirs.cache_dir().join("dht.json"); - Ok(path) - } - - #[inline(never)] - pub fn create<'a>( - config: Option, - cancellation_token: Option, - bind_device: Option<&'a BindDevice>, - ) -> BoxFuture<'a, anyhow::Result> { - async move { - let mut config = config.unwrap_or_default(); - let config_filename = match config.config_filename.take() { - Some(config_filename) => config_filename, - None => Self::default_persistence_filename()?, - }; - - info!( - filename=?config_filename, - "will store DHT routing table periodically", - ); - - if let Some(parent) = config_filename.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("error creating dir {:?}", &parent))?; - } - - let de = match OpenOptions::new().read(true).open(&config_filename) { - Ok(dht_json) => { - let reader = BufReader::new(dht_json); - match serde_json::from_reader::<_, DhtSerialize>( - reader, - ) { - Ok(mut r) => { - info!(filename=?config_filename, "loaded DHT routing table from"); - if config.ipv4_only { - // Force IPv4 if requested - let port = r.addr.port(); - r.addr = SocketAddr::from(([0, 0, 0, 0], port)); - } else if r.addr.ip() == Ipv4Addr::UNSPECIFIED { - warn!( - "patching DHT listen IP address for rtbit 9 upgrade: 0.0.0.0 -> [::]" - ); - let port = r.addr.port(); - r.addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, port)); - } - - Some(r) - } - Err(e) => { - warn!( - filename=?config_filename, - "DHT: cannot deserialize routing table: {:#}", - e - ); - None - } - } - } - Err(e) => match e.kind() { - std::io::ErrorKind::NotFound => None, - _ => { - return Err(e) - .with_context(|| format!("error reading {config_filename:?}")); - } - }, - }; - let (mut listen_addr, routing_table, peer_store) = de - .map(|de| (Some(de.addr), Some(de.table), de.peer_store)) - .unwrap_or((None, None, None)); - - if let Some(port) = config.port { - if let Some(ref mut addr) = listen_addr { - addr.set_port(port); - } else { - listen_addr = Some(SocketAddr::from((Ipv6Addr::UNSPECIFIED, port))); - } - } - let peer_id = routing_table.as_ref().map(|r| r.id()); - - let dht_config = DhtConfig { - peer_id, - routing_table, - listen_addr, - peer_store, - cancellation_token, - bind_device, - ..Default::default() - }; - let dht = DhtState::with_config(dht_config).await?; - spawn_with_cancel::( - debug_span!("dht_persistence"), - "dht_persistence", - dht.cancellation_token().clone(), - { - let dht = dht.clone(); - let dump_interval = config - .dump_interval - .unwrap_or_else(|| Duration::from_secs(60)); - async move { - let tempfile_name = { - let file_name = format!("dht.json.tmp.{}", std::process::id()); - let mut tmp = config_filename.clone(); - tmp.set_file_name(file_name); - tmp - }; - - loop { - trace!("sleeping for {:?}", &dump_interval); - tokio::time::sleep(dump_interval).await; - - match dump_dht(&dht, &config_filename, &tempfile_name) { - Ok(_) => trace!(filename=?config_filename, "dumped DHT"), - Err(e) => { - error!(filename=?config_filename, "error dumping DHT: {:#}", e) - } - } - } - } - }, - ); - - Ok(dht) - } - .boxed() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/routing_table.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/routing_table.rs deleted file mode 100644 index 34fd6468..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/routing_table.rs +++ /dev/null @@ -1,1026 +0,0 @@ -use std::{net::SocketAddr, time::Instant}; - -use librtbit_core::hash_id::Id20; -use rand::RngCore; -use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct}; -use tracing::{debug, trace}; - -use crate::INACTIVITY_TIMEOUT; - -#[derive(Clone, Debug)] -pub struct LeafBucket { - pub nodes: Vec, - pub last_refreshed: Instant, -} - -impl Serialize for LeafBucket { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut s = serializer.serialize_struct("LeafBucket", 2)?; - s.serialize_field("nodes", &self.nodes)?; - s.serialize_field( - "last_refreshed", - &format!("{:?}", self.last_refreshed.elapsed()), - )?; - s.end() - } -} - -impl<'de> Deserialize<'de> for LeafBucket { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct Tmp { - nodes: Vec, - } - Tmp::deserialize(deserializer).map(|t| Self { - nodes: t.nodes, - last_refreshed: Instant::now(), - }) - } -} - -impl Default for LeafBucket { - fn default() -> Self { - Self { - nodes: Default::default(), - last_refreshed: Instant::now(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -enum BucketTreeNodeData { - Leaf(LeafBucket), - LeftRight(usize, usize), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct BucketTreeNode { - bits: u8, - #[serde(serialize_with = "crate::utils::serialize_id20")] - start: Id20, - #[serde(serialize_with = "crate::utils::serialize_id20")] - end_inclusive: Id20, - data: BucketTreeNodeData, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct BucketTree { - data: Vec, - size: usize, - max_size: usize, -} - -pub struct BucketTreeIteratorItem<'a> { - pub bits: u8, - pub start: &'a Id20, - pub end_inclusive: &'a Id20, - pub leaf: &'a LeafBucket, -} - -impl BucketTreeIteratorItem<'_> { - pub fn random_within(&self) -> Id20 { - generate_random_id(self.start, self.bits) - } -} - -struct BucketTreeIterator<'a> { - tree: &'a BucketTree, - queue: Vec, -} - -impl<'a> BucketTreeIterator<'a> { - fn new(tree: &'a BucketTree) -> Self { - let queue = vec![0]; - BucketTreeIterator { tree, queue } - } -} - -impl<'a> Iterator for BucketTreeIterator<'a> { - type Item = BucketTreeIteratorItem<'a>; - - fn next(&mut self) -> Option { - loop { - let idx = self.queue.pop()?; - match self.tree.data.get(idx) { - Some(node) => match &node.data { - BucketTreeNodeData::Leaf(leaf) => { - return Some(BucketTreeIteratorItem { - bits: node.bits, - start: &node.start, - end_inclusive: &node.end_inclusive, - leaf, - }); - } - BucketTreeNodeData::LeftRight(left, right) => { - self.queue.push(*right); - self.queue.push(*left); - continue; - } - }, - None => continue, - } - } - } -} - -pub fn generate_random_id(start: &Id20, bits: u8) -> Id20 { - let mut data = [0u8; 20]; - rand::rng().fill_bytes(&mut data); - let mut data = Id20::new(data); - let remaining_bits = 160 - bits; - for bit in 0..remaining_bits { - data.set_bit(bit, start.get_bit(bit)); - } - data -} - -fn compute_split_start_end( - start: Id20, - end_inclusive: Id20, - bits: u8, -) -> ((Id20, Id20), (Id20, Id20)) { - let changing_bit = 160 - bits; - let new_left_end = { - let mut c = end_inclusive; - c.set_bit(changing_bit, false); - c - }; - let new_right_start = { - let mut c = start; - c.set_bit(changing_bit, true); - c - }; - debug_assert!( - start < new_left_end, - "expected start({start:?}) < new_left_end({new_left_end:?}); start={start:?}, end={end_inclusive:?}, bits={bits}", - ); - debug_assert!( - new_left_end < new_right_start, - "expected new_left_end({new_left_end:?}) < new_right_start({new_right_start:?}); start={start:?}, end={end_inclusive:?}, bits={bits}", - ); - debug_assert!( - new_right_start < end_inclusive, - "expected new_right_start({new_right_start:?}) < end_inclusive({end_inclusive:?}); start={start:?}, end={end_inclusive:?}, bits={bits}", - ); - ((start, new_left_end), (new_right_start, end_inclusive)) -} - -#[derive(Debug)] -pub enum InsertResult { - WasExisting, - ReplacedBad(RoutingTableNode), - Added, - Ignored, -} - -impl BucketTree { - pub fn new(max_size: usize) -> Self { - BucketTree { - data: vec![BucketTreeNode { - bits: 160, - start: Id20::new([0u8; 20]), - end_inclusive: Id20::new([0xff; 20]), - data: BucketTreeNodeData::Leaf(Default::default()), - }], - size: 0, - max_size, - } - } - - fn iter_leaves(&self) -> BucketTreeIterator<'_> { - BucketTreeIterator::new(self) - } - - fn iter(&self) -> impl Iterator + '_ { - self.iter_leaves().flat_map(|l| l.leaf.nodes.iter()) - } - - fn get_leaf(&self, id: &Id20) -> usize { - let mut idx = 0; - loop { - let node = &self.data[idx]; - match node.data { - BucketTreeNodeData::Leaf(_) => return idx, - BucketTreeNodeData::LeftRight(left_idx, right_idx) => { - let left = &self.data[left_idx]; - if *id >= left.start && *id <= left.end_inclusive { - idx = left_idx; - continue; - }; - idx = right_idx; - } - } - } - } - - pub fn get_mut( - &mut self, - id: &Id20, - refresh: Option, - ) -> Option<&mut RoutingTableNode> { - let idx = self.get_leaf(id); - match &mut self.data[idx].data { - BucketTreeNodeData::Leaf(leaf) => { - let r = leaf.nodes.iter_mut().find(|b| b.id == *id); - if r.is_some() - && let Some(refresh) = refresh - { - leaf.last_refreshed = refresh; - } - r - } - BucketTreeNodeData::LeftRight(_, _) => unreachable!(), - } - } - - pub fn add_node(&mut self, self_id: &Id20, id: Id20, addr: SocketAddr) -> InsertResult { - let idx = self.get_leaf(&id); - self.insert_into_leaf(idx, self_id, id, addr) - } - - fn insert_into_leaf( - &mut self, - mut idx: usize, - self_id: &Id20, - id: Id20, - addr: SocketAddr, - ) -> InsertResult { - // The loop here is for this case: - // in case we split a node into two, and it degenerates into all the leaves - // being on one side, we'll need to split again "recursively" until there's space - // for the new node. - // The loop is to remove the recursion. NOTE: it might have compiled to tail recursion - // anyway, but whatever, did not check. - let now = Instant::now(); - loop { - let leaf = &mut self.data[idx]; - let nodes = match &mut leaf.data { - BucketTreeNodeData::Leaf(nodes) => nodes, - BucketTreeNodeData::LeftRight(_, _) => unreachable!(), - }; - // if already found, quit - if nodes.nodes.iter().any(|r| r.id == id) { - return InsertResult::WasExisting; - } - - let mut new_node = RoutingTableNode { - id, - addr, - last_request: None, - last_response: None, - last_query: None, - errors_in_a_row: 0, - }; - - // Try replace a bad node - - if let Some(bad_node) = nodes - .nodes - .iter_mut() - .find(|r| matches!(r.status(now), NodeStatus::Bad)) - { - std::mem::swap(bad_node, &mut new_node); - nodes.nodes.sort_by_key(|n| n.id); - debug!("replaced bad node {:?}", new_node); - nodes.last_refreshed = now; - return InsertResult::ReplacedBad(new_node); - } - - // if max size reached, don't bother - if self.size == self.max_size { - trace!( - "can't add node to routing table, max size of {} reached", - self.max_size - ); - return InsertResult::Ignored; - } - - if nodes.nodes.len() < 8 { - nodes.nodes.push(new_node); - nodes.nodes.sort_by_key(|n| n.id); - nodes.last_refreshed = now; - self.size += 1; - return InsertResult::Added; - } - - // if our id is not inside, don't bother. - if *self_id < leaf.start || *self_id > leaf.end_inclusive { - return InsertResult::Ignored; - } - - // Split - let ((ls, le), (rs, re)) = - compute_split_start_end(leaf.start, leaf.end_inclusive, leaf.bits); - let (mut ld, mut rd) = (Vec::new(), Vec::new()); - for node in nodes.nodes.drain(0..) { - if node.id < rs { - ld.push(node); - } else { - rd.push(node) - } - } - - let left = BucketTreeNode { - bits: leaf.bits - 1, - start: ls, - end_inclusive: le, - data: BucketTreeNodeData::Leaf(LeafBucket { - nodes: ld, - ..Default::default() - }), - }; - let right = BucketTreeNode { - bits: leaf.bits - 1, - start: rs, - end_inclusive: re, - data: BucketTreeNodeData::Leaf(LeafBucket { - nodes: rd, - ..Default::default() - }), - }; - - let left_idx = { - let l = self.data.len(); - self.data.push(left); - l - }; - let right_idx = { - let l = self.data.len(); - self.data.push(right); - l - }; - - self.data[idx].data = BucketTreeNodeData::LeftRight(left_idx, right_idx); - if id < rs { - idx = left_idx - } else { - idx = right_idx - } - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RoutingTableNode { - #[serde(serialize_with = "crate::utils::serialize_id20")] - id: Id20, - addr: SocketAddr, - #[serde(skip)] - last_request: Option, - #[serde(skip)] - last_response: Option, - #[serde(skip)] - last_query: Option, - #[serde(skip)] - errors_in_a_row: usize, -} - -impl Serialize for RoutingTableNode { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - let mut s = serializer.serialize_struct("RoutingTableNode", 3)?; - s.serialize_field("id", &self.id.as_string())?; - s.serialize_field("addr", &self.addr)?; - s.serialize_field("status", &self.status(Instant::now()))?; - if let Some(l) = self.last_request { - s.serialize_field("last_request_ago", &l.elapsed())?; - } - if let Some(l) = self.last_response { - s.serialize_field("last_response_ago", &l.elapsed())?; - } - if let Some(l) = self.last_query { - s.serialize_field("last_query_ago", &l.elapsed())?; - } - s.serialize_field("errors_in_a_row", &self.errors_in_a_row)?; - s.end() - } -} - -#[derive(Serialize, Debug)] -pub enum NodeStatus { - Good, - Questionable, - Bad, - Unknown, -} - -impl RoutingTableNode { - pub fn id(&self) -> Id20 { - self.id - } - pub fn addr(&self) -> SocketAddr { - self.addr - } - pub fn status(&self, now: Instant) -> NodeStatus { - match (self.last_request, self.last_response, self.last_query) { - // Nodes become bad when they fail to respond to multiple queries in a row. - (Some(_), _, _) if self.errors_in_a_row >= 2 => NodeStatus::Bad, - - // A good node is a node has responded to one of our queries within the last 15 minutes. - // A node is also good if it has ever responded to one of our queries and has sent - // us a query within the last 15 minutes. - (Some(_), Some(last_incoming), _) | (Some(_), Some(_), Some(last_incoming)) - if now - last_incoming < INACTIVITY_TIMEOUT => - { - NodeStatus::Good - } - - // After 15 minutes of inactivity, a node becomes questionable. - // The moment we send a request to it, it stops becoming questionable and becomes Unknown / Bad. - (last_outgoing, _, Some(last_incoming)) | (last_outgoing, Some(last_incoming), _) - if now - last_incoming > INACTIVITY_TIMEOUT - && last_outgoing - .map(|e| now - e > INACTIVITY_TIMEOUT) - .unwrap_or(true) => - { - NodeStatus::Questionable - } - _ => NodeStatus::Unknown, - } - } - - pub fn mark_outgoing_request(&mut self, now: Instant) { - self.last_request = Some(now); - } - - pub fn mark_last_query(&mut self, now: Instant) { - self.last_query = Some(now); - } - - pub fn mark_response(&mut self, now: Instant) { - self.last_response = Some(now); - if self.last_request.is_none() { - self.last_request = Some(now); - } - self.errors_in_a_row = 0; - } - - pub fn mark_error(&mut self) { - self.errors_in_a_row += 1; - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RoutingTable { - #[serde(serialize_with = "crate::utils::serialize_id20")] - id: Id20, - size: usize, - buckets: BucketTree, -} - -impl RoutingTable { - const DEFAULT_MAX_SIZE: usize = 512; - - pub fn new(id: Id20, max_size: Option) -> Self { - Self { - id, - buckets: BucketTree::new(max_size.unwrap_or(Self::DEFAULT_MAX_SIZE)), - size: 0, - } - } - pub fn id(&self) -> Id20 { - self.id - } - pub fn len(&self) -> usize { - self.size - } - pub fn sorted_by_distance_from(&self, id: Id20, now: Instant) -> Vec<&RoutingTableNode> { - let mut result = Vec::with_capacity(self.size); - for node in self.buckets.iter() { - result.push(node); - } - result.sort_by_key(|n| { - // Query decent nodes first. - let status = match n.status(now) { - NodeStatus::Good => 0, - NodeStatus::Questionable => 1, - NodeStatus::Unknown => 2, - NodeStatus::Bad => 3, - }; - (status, id.distance(&n.id)) - }); - result - } - - pub fn iter_buckets(&self) -> impl Iterator> + '_ { - self.buckets.iter_leaves() - } - - pub fn iter(&self) -> impl Iterator + '_ { - self.buckets.iter() - } - - pub fn add_node(&mut self, id: Id20, addr: SocketAddr) -> InsertResult { - let res = self.buckets.add_node(&self.id, id, addr); - let replaced = match &res { - InsertResult::WasExisting => false, - InsertResult::ReplacedBad(..) => true, - InsertResult::Added => true, - InsertResult::Ignored => false, - }; - if replaced { - self.size += 1; - } - res - } - pub fn mark_outgoing_request(&mut self, id: &Id20, now: Instant) -> bool { - let r = match self.buckets.get_mut(id, None) { - Some(r) => r, - None => return false, - }; - r.mark_outgoing_request(now); - true - } - - pub fn mark_response(&mut self, id: &Id20, now: Instant) -> bool { - let r = match self.buckets.get_mut(id, Some(now)) { - Some(r) => r, - None => return false, - }; - r.mark_response(now); - true - } - - pub fn mark_error(&mut self, id: &Id20) -> bool { - let r = match self.buckets.get_mut(id, None) { - Some(r) => r, - None => return false, - }; - r.mark_error(); - true - } - - pub fn mark_last_query(&mut self, id: &Id20, now: Instant) -> bool { - let r = match self.buckets.get_mut(id, None) { - Some(r) => r, - None => return false, - }; - r.mark_last_query(now); - true - } -} - -#[cfg(test)] -mod tests { - use std::{ - io::Cursor, - net::{Ipv4Addr, SocketAddr, SocketAddrV4}, - str::FromStr, - time::Instant, - }; - - use librtbit_core::hash_id::Id20; - use rand::Rng; - - use crate::routing_table::compute_split_start_end; - - use super::{RoutingTable, generate_random_id}; - - #[test] - fn compute_split_start_end_root() { - let start = Id20::new([0u8; 20]); - let end = Id20::new([0xff; 20]); - assert_eq!( - compute_split_start_end(start, end, 160), - ( - ( - start, - Id20::from_str("7fffffffffffffffffffffffffffffffffffffff").unwrap() - ), - ( - Id20::from_str("8000000000000000000000000000000000000000").unwrap(), - end - ) - ) - ) - } - - #[test] - fn compute_split_start_end_second_split() { - let start = Id20::from_str("8000000000000000000000000000000000000000").unwrap(); - let end = Id20::new([0xff; 20]); - assert_eq!( - compute_split_start_end(start, end, 159), - ( - ( - start, - Id20::from_str("bfffffffffffffffffffffffffffffffffffffff").unwrap() - ), - ( - Id20::from_str("c000000000000000000000000000000000000000").unwrap(), - end - ) - ) - ) - } - - #[test] - fn compute_split_start_end_3() { - let start = Id20::from_str("8000000000000000000000000000000000000000").unwrap(); - let end = Id20::new([0xff; 20]); - assert_eq!( - compute_split_start_end(start, end, 159), - ( - ( - start, - Id20::from_str("bfffffffffffffffffffffffffffffffffffffff").unwrap() - ), - ( - Id20::from_str("c000000000000000000000000000000000000000").unwrap(), - end - ) - ) - ) - } - - fn random_id_20() -> Id20 { - let mut id20 = [0u8; 20]; - rand::rng().fill(&mut id20); - Id20::new(id20) - } - - fn generate_socket_addr() -> SocketAddr { - let mut ipv4_addr = [0u8; 6]; - rand::rng().fill(&mut ipv4_addr); - let ip = Ipv4Addr::new(ipv4_addr[0], ipv4_addr[1], ipv4_addr[2], ipv4_addr[3]); - let port = ((ipv4_addr[4] as u16) << 8) + (ipv4_addr[5] as u16); - SocketAddrV4::new(ip, port).into() - } - - fn generate_table(length: Option) -> RoutingTable { - let my_id = random_id_20(); - let mut rtable = RoutingTable::new(my_id, None); - for _ in 0..length.unwrap_or(16536) { - let other_id = random_id_20(); - let addr = generate_socket_addr(); - rtable.add_node(other_id, addr); - } - rtable - } - - #[test] - fn test_iter_is_ordered() { - let table = generate_table(None); - let mut it = table.buckets.iter(); - let mut previous = it.next().unwrap(); - for node in it { - assert!(node.id() > previous.id()); - previous = node; - } - } - - #[test] - fn test_sorted_by_distance_from() { - let id = random_id_20(); - let rtable = generate_table(None); - assert_eq!( - rtable.sorted_by_distance_from(id, Instant::now()).len(), - rtable.size - ); - } - - #[test] - fn serialize_deserialize_routing_table() { - let table = generate_table(Some(1000)); - let v = serde_json::to_vec(&table).unwrap(); - let _: RoutingTable = serde_json::from_reader(Cursor::new(v)).unwrap(); - } - - #[test] - fn test_generate_random_id() { - let start = Id20::from_str("3000000000000000000000000000000000000000").unwrap(); - let end = Id20::from_str("3fffffffffffffffffffffffffffffffffffffff").unwrap(); - let bits = 156; - for _ in 0..100 { - let id = dbg!(generate_random_id(&start, bits)); - assert!(id >= start && id <= end, "{:?}", id); - } - } - - // ----------------------------------------------------------------------- - // test_add_node_to_routing_table — basic node addition - // ----------------------------------------------------------------------- - #[test] - fn test_add_node_to_routing_table() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - assert_eq!(rt.len(), 0); - - let other_id = Id20::from_str("0000000000000000000000000000000000000001").unwrap(); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 6881).into(); - let result = rt.add_node(other_id, addr); - assert!(matches!(result, super::InsertResult::Added)); - assert_eq!(rt.len(), 1); - } - - // ----------------------------------------------------------------------- - // test_add_duplicate_node — adding the same node twice is WasExisting - // ----------------------------------------------------------------------- - #[test] - fn test_add_duplicate_node() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - let other_id = Id20::from_str("0000000000000000000000000000000000000001").unwrap(); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 6881).into(); - - rt.add_node(other_id, addr); - let result = rt.add_node(other_id, addr); - assert!(matches!(result, super::InsertResult::WasExisting)); - // Size should not change on duplicate. - assert_eq!(rt.len(), 1); - } - - // ----------------------------------------------------------------------- - // test_routing_table_respects_bucket_size — k=8 bucket limit - // ----------------------------------------------------------------------- - #[test] - fn test_routing_table_respects_bucket_size() { - // Place our own ID far from all added nodes so the bucket won't split. - let my_id = Id20::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - // Add 8 nodes in the 0x00.. range — fills one bucket. - for i in 0u8..8 { - let mut data = [0u8; 20]; - data[19] = i + 1; // 0x01..0x08 - let id = Id20::new(data); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, i + 1), 6881).into(); - let result = rt.add_node(id, addr); - assert!( - matches!(result, super::InsertResult::Added), - "node {i} should be added" - ); - } - assert_eq!(rt.len(), 8); - - // The 9th node in the same range should be Ignored (bucket full, our id is not in range). - let mut data = [0u8; 20]; - data[19] = 9; - let id = Id20::new(data); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 6881).into(); - let result = rt.add_node(id, addr); - assert!( - matches!(result, super::InsertResult::Ignored), - "9th node should be ignored" - ); - assert_eq!(rt.len(), 8); - } - - // ----------------------------------------------------------------------- - // test_routing_table_splitting — buckets split when own ID is in range - // ----------------------------------------------------------------------- - #[test] - fn test_routing_table_splitting() { - // Our ID is in the 0x00.. range, so the bucket containing us can split. - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - // Add 9 nodes across the whole range — the bucket should split to accommodate the 9th. - for i in 1u8..=9 { - let mut data = [0u8; 20]; - data[0] = i * 16; // spread across the ID space - let id = Id20::new(data); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, i), 6881).into(); - rt.add_node(id, addr); - } - // All 9 should be added because the bucket containing our id will split. - assert!(rt.len() >= 9, "expected at least 9 nodes, got {}", rt.len()); - } - - // ----------------------------------------------------------------------- - // test_routing_table_no_split_when_full_and_far - // ----------------------------------------------------------------------- - #[test] - fn test_routing_table_no_split_when_full_and_far() { - // Our ID is in 0xFF.., so the 0x00.. bucket is far and should not split. - let my_id = Id20::from_str("ff00000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - // Add 8 nodes in the 0x00.. range. - for i in 1u8..=8 { - let mut data = [0u8; 20]; - data[19] = i; - let id = Id20::new(data); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, i), 6881).into(); - rt.add_node(id, addr); - } - assert_eq!(rt.len(), 8); - - // 9th node in the same far range should be ignored. - let mut data = [0u8; 20]; - data[19] = 9; - let result = rt.add_node( - Id20::new(data), - SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 6881).into(), - ); - assert!(matches!(result, super::InsertResult::Ignored)); - } - - // ----------------------------------------------------------------------- - // test_closest_nodes — returns nodes sorted by distance - // ----------------------------------------------------------------------- - #[test] - fn test_closest_nodes() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - let ids: Vec = (1u8..=5) - .map(|i| { - let mut data = [0u8; 20]; - data[0] = i; - Id20::new(data) - }) - .collect(); - - for (i, id) in ids.iter().enumerate() { - let addr: SocketAddr = - SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, i as u8 + 1), 6881).into(); - rt.add_node(*id, addr); - } - - let target = Id20::from_str("0200000000000000000000000000000000000000").unwrap(); - let sorted = rt.sorted_by_distance_from(target, Instant::now()); - assert_eq!(sorted.len(), 5); - - // The closest to 0x02 should be 0x02 (distance=0), then 0x03 (distance=1), - // then 0x01 (distance=3), etc. All nodes are Unknown status so only distance matters. - assert_eq!(sorted[0].id(), ids[1]); // 0x02 - } - - // ----------------------------------------------------------------------- - // test_mark_node_responded — state transition to Good - // ----------------------------------------------------------------------- - #[test] - fn test_mark_node_responded() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - let node_id = Id20::from_str("0000000000000000000000000000000000000001").unwrap(); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 6881).into(); - - rt.add_node(node_id, addr); - - // Initially the node is Unknown (no request/response recorded). - let now = Instant::now(); - let node = rt.buckets.get_mut(&node_id, None).unwrap(); - assert!(matches!(node.status(now), super::NodeStatus::Unknown)); - - // Mark outgoing request, then response. - rt.mark_outgoing_request(&node_id, now); - rt.mark_response(&node_id, now); - - let node = rt.buckets.get_mut(&node_id, None).unwrap(); - assert!( - matches!(node.status(Instant::now()), super::NodeStatus::Good), - "node should be Good after response" - ); - } - - // ----------------------------------------------------------------------- - // test_mark_node_errored — error counting and Bad detection - // ----------------------------------------------------------------------- - #[test] - fn test_mark_node_errored() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - let node_id = Id20::from_str("0000000000000000000000000000000000000001").unwrap(); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 1), 6881).into(); - - rt.add_node(node_id, addr); - - let now = Instant::now(); - // Simulate: we sent a request, then get errors. - rt.mark_outgoing_request(&node_id, now); - rt.mark_error(&node_id); - rt.mark_error(&node_id); - - let node = rt.buckets.get_mut(&node_id, None).unwrap(); - assert!( - matches!(node.status(Instant::now()), super::NodeStatus::Bad), - "node should be Bad after 2 errors with a pending request" - ); - } - - // ----------------------------------------------------------------------- - // test_mark_error_on_unknown_node - // ----------------------------------------------------------------------- - #[test] - fn test_mark_error_on_unknown_node() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - let nonexistent = Id20::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); - // Should return false for a node not in the table. - assert!(!rt.mark_error(&nonexistent)); - assert!(!rt.mark_response(&nonexistent, Instant::now())); - assert!(!rt.mark_outgoing_request(&nonexistent, Instant::now())); - } - - // ----------------------------------------------------------------------- - // test_routing_table_serialization_roundtrip - // ----------------------------------------------------------------------- - #[test] - fn test_routing_table_serialization_roundtrip() { - let table = generate_table(Some(100)); - let original_size = table.len(); - - let serialized = serde_json::to_vec(&table).unwrap(); - let deserialized: RoutingTable = serde_json::from_slice(&serialized).unwrap(); - - assert_eq!(deserialized.id(), table.id()); - // The deserialized table should have the same number of nodes. - let mut count = 0; - for _ in deserialized.iter() { - count += 1; - } - assert_eq!(count, original_size); - } - - // ----------------------------------------------------------------------- - // test_routing_table_max_size_limit - // ----------------------------------------------------------------------- - #[test] - fn test_routing_table_max_size_limit() { - let my_id = Id20::from_str("0000000000000000000000000000000000000000").unwrap(); - let max_size = 16; - let mut rt = RoutingTable::new(my_id, Some(max_size)); - - // Try to add many more nodes than max_size. - for i in 0u32..1000 { - let mut data = [0u8; 20]; - data[0..4].copy_from_slice(&i.to_be_bytes()); - let id = Id20::new(data); - let port = (i % 65535) as u16 + 1; - let addr: SocketAddr = - SocketAddrV4::new(Ipv4Addr::new(10, 0, (i >> 8) as u8, i as u8), port).into(); - rt.add_node(id, addr); - } - - assert!( - rt.len() <= max_size, - "routing table should not exceed max_size={max_size}, got {}", - rt.len() - ); - } - - // ----------------------------------------------------------------------- - // test_replace_bad_node - // ----------------------------------------------------------------------- - #[test] - fn test_replace_bad_node() { - // Our ID is far so the bucket won't split. - let my_id = Id20::from_str("ffffffffffffffffffffffffffffffffffffffff").unwrap(); - let mut rt = RoutingTable::new(my_id, None); - - // Fill a bucket with 8 nodes. - let mut node_ids = Vec::new(); - for i in 1u8..=8 { - let mut data = [0u8; 20]; - data[19] = i; - let id = Id20::new(data); - node_ids.push(id); - let addr: SocketAddr = SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, i), 6881).into(); - rt.add_node(id, addr); - } - assert_eq!(rt.len(), 8); - - // Make the first node "bad": send it a request, then give it 2 errors. - let bad_id = node_ids[0]; - let now = Instant::now(); - rt.mark_outgoing_request(&bad_id, now); - rt.mark_error(&bad_id); - rt.mark_error(&bad_id); - - // Verify it's bad. - { - let node = rt.buckets.get_mut(&bad_id, None).unwrap(); - assert!(matches!( - node.status(Instant::now()), - super::NodeStatus::Bad - )); - } - - // Now add a 9th node — it should replace the bad one. - let mut data = [0u8; 20]; - data[19] = 9; - let new_id = Id20::new(data); - let result = rt.add_node( - new_id, - SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 9), 6881).into(), - ); - assert!( - matches!(result, super::InsertResult::ReplacedBad(_)), - "should replace the bad node" - ); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/utils.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/utils.rs deleted file mode 100644 index 9028b72a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/dht/src/utils.rs +++ /dev/null @@ -1,9 +0,0 @@ -use librtbit_core::hash_id::Id20; -use serde::Serializer; - -pub fn serialize_id20(id: &Id20, ser: S) -> Result -where - S: Serializer, -{ - ser.serialize_str(&id.as_string()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/.gitignore b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/.gitignore deleted file mode 100644 index b83d2226..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target/ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/Cargo.toml b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/Cargo.toml deleted file mode 100644 index a55b7193..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/Cargo.toml +++ /dev/null @@ -1,142 +0,0 @@ -[package] -name = "librtbit" -version = "0.0.1" -authors = ["Igor Katson "] -edition = "2024" -description = "The main library used by rtbit torrent client. The binary is just a small wrapper on top of it." -license = "Apache-2.0" -documentation = "https://docs.rs/librtbit" -repository = "https://github.com/ikatson/rtbit" -readme = "README.md" - -[lints] -workspace = true - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[features] -default = ["default-tls", "http-api-client"] - -tokio-console = ["console-subscriber", "tokio/tracing"] -http-api = ["axum", "tower-http"] -http-api-client = [] -upnp-serve-adapter = ["upnp-serve"] -webui = [] -timed_existence = [] -default-tls = [ - "reqwest/default-tls", - "sha1w/sha1-crypto-hash", - "librtbit-core/sha1-crypto-hash", - "dht/sha1-crypto-hash", -] -miri = [] -prometheus = ["metrics-exporter-prometheus"] -rust-tls = ["reqwest/rustls-tls", "sha1w/sha1-ring", "librtbit-core/sha1-ring", "dht/sha1-ring"] -storage_middleware = ["lru"] -storage_examples = [] -tracing-subscriber-utils = ["tracing-subscriber"] -postgres = ["sqlx", "home"] -async-bt = ["async-backtrace"] -watch = ["notify"] -disable-upload = [] -swagger = ["utoipa", "utoipa-axum", "utoipa-swagger-ui"] - -# These are absolutely useless for people, just for debugging / testing / benchmarking -_disable_disk_write_net_benchmark = [] -_disable_reconnect_test = [] - -[dependencies] -# sqlx and home are pinned so that we can compile on older Rusts -sqlx = { workspace = true, features = ["runtime-tokio", "macros", "postgres"], optional = true } -home = { workspace = true, optional = true } - -chrono = { workspace = true, features = ["serde"] } -feed-rs.workspace = true -rusqlite.workspace = true - -bencode.workspace = true -tracker_comms.workspace = true -buffers.workspace = true -librtbit-core.workspace = true -librtbit-lsd.workspace = true -clone_to_owned.workspace = true -peer_binary_protocol.workspace = true -sha1w.workspace = true -dht.workspace = true -librtbit-upnp.workspace = true -upnp-serve = { workspace = true, optional = true } - -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "io-util"] } -governor.workspace = true -console-subscriber = { workspace = true, optional = true } -axum = { workspace = true, optional = true, features = ["multipart"] } -tower-http = { workspace = true, features = ["cors", "trace"], optional = true } -tokio-stream.workspace = true -serde = { workspace = true, features = ["rc"] } -serde_derive.workspace = true -serde_json.workspace = true -serde_urlencoded.workspace = true -anyhow.workspace = true -itertools.workspace = true -http.workspace = true -regex.workspace = true -reqwest = { workspace = true, features = ["json", "socks", "stream"] } -urlencoding.workspace = true -byteorder.workspace = true -bincode.workspace = true -bitvec.workspace = true -crc32fast.workspace = true -parking_lot.workspace = true -tracing.workspace = true -size_format.workspace = true -rand.workspace = true -tracing-subscriber = { workspace = true, features = ["json", "fmt", "env-filter"], optional = true } -uuid = { workspace = true, features = ["v4"] } -futures.workspace = true -url = { workspace = true, features = ["serde"] } -hex.workspace = true -backon.workspace = true -dashmap.workspace = true -base64.workspace = true -serde_with.workspace = true -tokio-util = { workspace = true, features = ["io"] } -metrics-exporter-prometheus = { workspace = true, optional = true } -bytes.workspace = true -rlimit.workspace = true -async-stream.workspace = true -memmap2.workspace = true -lru = { workspace = true, optional = true } -mime_guess.workspace = true -tokio-socks.workspace = true -async-trait.workspace = true -async-backtrace = { workspace = true, optional = true } -notify = { workspace = true, optional = true } -walkdir.workspace = true -arc-swap.workspace = true -intervaltree.workspace = true -async-compression = { workspace = true, features = ["tokio", "gzip"] } -librqbit-utp = { workspace = true, features = ["export-metrics"] } -axum-extra = { workspace = true, features = ["query"] } -librqbit-dualstack-sockets = { workspace = true, features = ["axum"] } -socket2.workspace = true -nix = { workspace = true, features = ["uio"] } -thiserror.workspace = true -utoipa = { workspace = true, optional = true } -utoipa-axum = { workspace = true, optional = true } -utoipa-swagger-ui = { workspace = true, features = ["axum"], optional = true } - -[target.'cfg(windows)'.dependencies] -windows = { workspace = true, features = ["Win32", "Win32_System", "Win32_System_IO", "Win32_System_Ioctl"] } - -[build-dependencies] -anyhow.workspace = true - -[dev-dependencies] -tracing-subscriber = { workspace = true, features = ["fmt", "std"] } -tokio-test.workspace = true -tempfile.workspace = true -pollster.workspace = true - -[[example]] -name = "simulate_traffic" -required-features = ["http-api", "webui", "tracing-subscriber-utils", "prometheus", "swagger"] diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/README.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/README.md deleted file mode 100644 index 3664771b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# librtbit - -A fully featured, easy to use torrent downloading library used as a backbone of [rtbit](https://github.com/ikatson/rtbit). - -## Basic example -See [examples on GitHub](https://github.com/ikatson/rtbit/tree/main/crates/librtbit/examples). - -## Documentation -[librtbit at docs.rs](https://docs.rs/librtbit/latest/librtbit/) \ No newline at end of file diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/build.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/build.rs deleted file mode 100644 index 177033ad..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/build.rs +++ /dev/null @@ -1,55 +0,0 @@ -use anyhow::{Context, bail}; -use std::path::Path; -use std::process::Command; - -#[allow(dead_code)] -fn run_cmd(cwd: &Path, cmd: &str) -> anyhow::Result<()> { - #[cfg(target_os = "windows")] - let (shell, shell_args) = ("powershell", ["-command"].as_slice()); - #[cfg(not(target_os = "windows"))] - let (shell, shell_args) = ("sh", ["-c"].as_slice()); - - // Run "npm install" in the webui directory - let output = Command::new(shell) - .args(shell_args) - .arg(cmd) - .current_dir(cwd) - .output() - .with_context(|| { - format!( - "Failed to execute {} in {:?}. PATH: {:?}", - cmd, - cwd, - std::env::var("PATH").unwrap_or_default() - ) - })?; - - if !output.status.success() { - bail!( - "\"{}\" failed\n\nstderr: {}\n\nstdout: {}", - cmd, - String::from_utf8_lossy(&output.stderr), - String::from_utf8_lossy(&output.stdout) - ); - } - - // Optionally print the stdout output if you want to see the build logs - println!("{}", String::from_utf8_lossy(&output.stdout)); - - Ok(()) -} - -fn main() { - #[cfg(feature = "webui")] - { - let webui_dir = Path::new("webui"); - let webui_src_dir = webui_dir.join("src"); - - println!("cargo:rerun-if-changed={}", webui_src_dir.to_str().unwrap()); - - // Run "npm install && npm run build" in the webui directory - for cmd in ["npm install", "npm run build"] { - run_cmd(webui_dir, cmd).unwrap(); - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/custom_storage.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/custom_storage.rs deleted file mode 100644 index e7efe1a5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/custom_storage.rs +++ /dev/null @@ -1,112 +0,0 @@ -use bytes::Bytes; -use librtbit::{ - SessionOptions, - storage::{StorageFactory, StorageFactoryExt, TorrentStorage}, -}; -use tracing::info; - -use std::time::Duration; - -#[derive(Default, Clone, Copy)] -struct CustomStorageFactory { - _some_state_used_to_create_per_torrent_storage: (), -} - -#[derive(Default, Clone, Copy)] -struct CustomStorage { - _some_state_for_per_torrent_storage: (), -} - -impl StorageFactory for CustomStorageFactory { - type Storage = CustomStorage; - - fn create( - &self, - _: &librtbit::ManagedTorrentShared, - _: &librtbit::TorrentMetadata, - ) -> anyhow::Result { - Ok(CustomStorage::default()) - } - - fn clone_box(&self) -> librtbit::storage::BoxStorageFactory { - self.boxed() - } -} - -impl TorrentStorage for CustomStorage { - fn pread_exact(&self, _file_id: usize, _offset: u64, _buf: &mut [u8]) -> anyhow::Result<()> { - anyhow::bail!("not implemented") - } - - fn pwrite_all(&self, _file_id: usize, _offset: u64, _buf: &[u8]) -> anyhow::Result<()> { - anyhow::bail!("not implemented") - } - - fn remove_file(&self, _file_id: usize, _filename: &std::path::Path) -> anyhow::Result<()> { - anyhow::bail!("not implemented") - } - - fn ensure_file_length(&self, _file_id: usize, _length: u64) -> anyhow::Result<()> { - anyhow::bail!("not implemented") - } - - fn take(&self) -> anyhow::Result> { - anyhow::bail!("not implemented") - } - - fn remove_directory_if_empty(&self, _path: &std::path::Path) -> anyhow::Result<()> { - anyhow::bail!("not implemented") - } - - fn init( - &mut self, - _meta: &librtbit::ManagedTorrentShared, - _: &librtbit::TorrentMetadata, - ) -> anyhow::Result<()> { - anyhow::bail!("not implemented") - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Output logs to console. - match std::env::var("RUST_LOG") { - Ok(_) => {} - Err(_) => unsafe { std::env::set_var("RUST_LOG", "info") }, - } - tracing_subscriber::fmt::init(); - let s = librtbit::Session::new_with_opts( - Default::default(), - SessionOptions { - disable_dht_persistence: true, - persistence: None, - ..Default::default() - }, - ) - .await?; - let handle = s - .add_torrent( - librtbit::AddTorrent::TorrentFileBytes(Bytes::from_static(include_bytes!( - "../resources/ubuntu-21.04-live-server-amd64.iso.torrent" - ))), - Some(librtbit::AddTorrentOptions { - storage_factory: Some(CustomStorageFactory::default().boxed()), - paused: false, - ..Default::default() - }), - ) - .await? - .into_handle() - .unwrap(); - tokio::spawn({ - let h = handle.clone(); - async move { - loop { - info!("{}", h.stats()); - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - }); - handle.wait_until_completed().await?; - Ok(()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/simulate_traffic.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/simulate_traffic.rs deleted file mode 100644 index 49ba368c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/simulate_traffic.rs +++ /dev/null @@ -1,352 +0,0 @@ -use std::{ - io::Write, - net::Ipv6Addr, - num::NonZero, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; - -use anyhow::Context; -use bytes::Bytes; -use librqbit_dualstack_sockets::{BindOpts, TcpListener}; -use librtbit::{ - AddTorrent, AddTorrentOptions, Api, ConnectionOptions, CreateTorrentOptions, - CreateTorrentResult, ListenerOptions, PeerConnectionOptions, Session, SessionOptions, - create_torrent, generate_azereus_style, - http_api::{HttpApi, HttpApiOptions}, - limits::LimitsConfig, - spawn_utils::BlockingSpawner, - tracing_subscriber_config_utils::{InitLoggingOptions, init_logging}, -}; -use librtbit_core::constants::CHUNK_SIZE; -use rand::{RngCore, SeedableRng, seq::IndexedRandom}; -use tracing::info; - -/// Base port for test sessions. Main uses 50000, peers use 50001+. -const BASE_PORT: u16 = 50000; - -struct TestHarness { - td: PathBuf, - torrents: Vec, -} - -fn create_new_file_with_random_content(path: &Path, mut size: usize) { - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(path) - .unwrap(); - - info!(?path, "creating temp file"); - - const BUF_SIZE: usize = 8192 * 16; - let mut rng = rand::rngs::SmallRng::from_os_rng(); - let mut write_buf = [0; BUF_SIZE]; - while size > 0 { - rng.fill_bytes(&mut write_buf[..]); - let written = file.write(&write_buf[..size.min(BUF_SIZE)]).unwrap(); - size -= written; - } -} - -fn create_default_random_dir_with_torrents(dir: &Path, num_files: usize, file_size: usize) { - for f in 0..num_files { - create_new_file_with_random_content(&dir.join(format!("{f}.data")), file_size); - } -} - -fn generate_unique_torrent_name(index: usize) -> String { - static TORRENT_NAMES: &[&str] = &[ - "Ubuntu 24.04 LTS Desktop amd64", - "Arch.Linux.2026.01.01.x86_64.Rolling-RELEASE", - "Debian.12.5.0.DVD.x64-iND", - "Fedora-Workstation-41-x86_64 [ISO]", - "Kali Linux 2025 4 Installer amd64", - "Manjaro.KDE.Plasma.v24.0.Stable.x64.Multi-Language", - "linuxmint-22-cinnamon-64bit-v2", - "Tails amd64 v6.0.1 IMG", - "NixOS_24.11_x86_64_Plasma_Gnome_Dual_Boot", - "Pop_OS_22.04_Intel_Nvidia_Edition", - "Gentoo.Admin.CD.20241229.x86", - "AlmaLinux-9.4-x86_64-Boot-Full", - "FreeBSD-14.1-STABLE-amd64-KMS", - "openSUSE.Leap.15.6.DVD.x86_64.Build.0412-Scene", - "Slackware 15 0 x64 DVD1", - "Void.Live.x86_64.20240314.XFCE.PROPER-GRP", - "EndeavourOS_Galileo_v2_Neo", - "Rocky-9.3-x86_64-Minimal-Standard", - "Zorin-OS-17.1-Core-64bit.iso", - "Alpine.3.20.0.Virt", - "TrueNAS.Scale.24.04.0.Dragonfish", - "Clonezilla.Live.3.1.2.AMD64.NoArch", - "Garuda-Dr460nized-240501-Gaming", - "Puppy_Linux_9.5_x64", - "SteamOS-Holoiso-v3.5.19-Deck-Experience", - "Red.Hat.Enterprise.Linux.v9.4.x64.dvd-TiNYiSO", - "CentOS-Stream-9-latest-x86_64-dvd1.iso", - "ElementaryOS-7.1-Horus-20240520", - "Parrot-Security-6.0-LTS-amd64", - "Lubuntu.24.04.LTS.Minimal.Install.x64", - ]; - - let base_name = TORRENT_NAMES[index % TORRENT_NAMES.len()]; - let cycle = index / TORRENT_NAMES.len(); - - if cycle == 0 { - base_name.to_string() - } else { - format!("{} ({})", base_name, cycle + 1) - } -} - -async fn create_one(path: &Path, index: usize) -> anyhow::Result { - create_default_random_dir_with_torrents( - path, - rand::random_range(2..10), - rand::random_range(1..8) * 1024 * 1024, - ); - create_torrent( - path, - CreateTorrentOptions { - name: Some(&generate_unique_torrent_name(index)), - piece_length: Some(CHUNK_SIZE), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await -} - -struct FakeTorrent { - root: PathBuf, - torrent_file: Bytes, -} - -async fn create_torrents(td: &Path, count: usize) -> anyhow::Result> { - let mut result = Vec::new(); - for torrent in 0..count { - let dir = td.join(torrent.to_string()); - tokio::fs::create_dir_all(&dir).await?; - let res = create_one(&dir, torrent).await?; - result.push(FakeTorrent { - root: dir, - torrent_file: res.as_bytes()?, - }) - } - Ok(result) -} - -impl TestHarness { - async fn run_forever(self) -> anyhow::Result<()> { - let _peers = self.start_peers().await?; - self.run_main().await.context("error running main") - } - - async fn start_peer(&self, id: usize) -> anyhow::Result> { - let out = self.td.join(format!("peer_{}", id)); - let listen_mode = [ - librtbit::ListenerMode::TcpOnly, - librtbit::ListenerMode::UtpOnly, - ] - .choose(&mut rand::rng()) - .copied() - .unwrap(); - - let listen_port = BASE_PORT + 1 + id as u16; // 50001, 50002, etc. - let peer_id = generate_azereus_style(*b"rQ", librtbit_core::crate_version!()); - let root_span = tracing::info_span!("peer", id, port = listen_port); - - let session = Session::new_with_opts( - out.clone(), - SessionOptions { - disable_dht: true, - disable_dht_persistence: true, - fastresume: false, - persistence: None, - peer_id: Some(peer_id), - root_span: Some(root_span), - listen: Some(ListenerOptions { - mode: listen_mode, - listen_addr: (Ipv6Addr::UNSPECIFIED, listen_port).into(), - enable_upnp_port_forwarding: false, - utp_opts: None, - announce_port: None, - ipv4_only: false, - }), - ratelimits: LimitsConfig { - upload_bps: NonZero::new(64 * 1024), - download_bps: Default::default(), - peer_limit: None, - concurrent_init_limit: None, - }, - disable_local_service_discovery: false, - connect: Some(ConnectionOptions { - proxy_url: None, - enable_tcp: listen_mode.tcp_enabled(), - peer_opts: Some(PeerConnectionOptions { - connect_timeout: Some(Duration::from_secs(1)), - read_write_timeout: Some(Duration::from_secs(32)), - keep_alive_interval: None, - }), - }), - ..Default::default() - }, - ) - .await?; - for (tid, torrent) in self.torrents.iter().enumerate() { - let opts = if tid == id { - Some(AddTorrentOptions { - overwrite: true, - output_folder: Some(torrent.root.to_string_lossy().into_owned()), - ..Default::default() - }) - } else { - // Don't cross-seed too often - if rand::random_bool(0.9) { - continue; - } - Some(AddTorrentOptions { - output_folder: Some( - out.join(format!("torrent_{}", tid)) - .to_str() - .map(|s| s.to_owned()) - .unwrap(), - ), - ..Default::default() - }) - }; - let t = session - .add_torrent( - AddTorrent::TorrentFileBytes(torrent.torrent_file.clone()), - opts, - ) - .await - .with_context(|| format!("error adding torrent {tid}"))? - .into_handle() - .context("into handle")?; - if tid == id { - t.wait_until_completed().await?; - } - } - Ok(session) - } - - async fn start_peers(&self) -> anyhow::Result>> { - let mut peers = Vec::new(); - for id in 0..self.torrents.len() { - peers.push( - self.start_peer(id) - .await - .with_context(|| format!("error starting peer {id}"))?, - ); - } - - Ok(peers) - } - - async fn run_main(&self) -> anyhow::Result<()> { - let path = self.td.join("main"); - - let peer_id = generate_azereus_style(*b"rQ", librtbit_core::crate_version!()); - let root_span = tracing::info_span!("main", port = BASE_PORT); - - let session = Session::new_with_opts( - path.clone(), - SessionOptions { - disable_dht: true, - disable_dht_persistence: true, - fastresume: false, - persistence: None, - peer_id: Some(peer_id), - root_span: Some(root_span), - listen: Some(ListenerOptions { - mode: librtbit::ListenerMode::TcpAndUtp, - listen_addr: (Ipv6Addr::UNSPECIFIED, BASE_PORT).into(), - enable_upnp_port_forwarding: false, - utp_opts: None, - announce_port: None, - ipv4_only: false, - }), - disable_local_service_discovery: false, - connect: Some(ConnectionOptions { - proxy_url: None, - enable_tcp: true, - peer_opts: Some(PeerConnectionOptions { - connect_timeout: Some(Duration::from_secs(1)), - read_write_timeout: Some(Duration::from_secs(32)), - keep_alive_interval: None, - }), - }), - ..Default::default() - }, - ) - .await?; - for (id, torrent) in self.torrents.iter().enumerate() { - session - .add_torrent( - AddTorrent::from_bytes(torrent.torrent_file.clone()), - Some(AddTorrentOptions { - output_folder: Some( - path.join(format!("torrent_{id}")) - .to_str() - .unwrap() - .to_owned(), - ), - ..Default::default() - }), - ) - .await - .with_context(|| format!("error adding torrent {id}"))?; - } - - let api = Api::new(session.clone(), None, None); - let http = HttpApi::new( - api, - Some(HttpApiOptions { - read_only: false, - allow_create: true, - ..Default::default() - }), - ); - let sock = TcpListener::bind_tcp( - (Ipv6Addr::UNSPECIFIED, 3030).into(), - BindOpts { - request_dualstack: true, - reuseport: true, - device: None, - }, - )?; - - http.make_http_api_and_run(sock, None).await - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let root = std::env::temp_dir().join("rtbit-simulate-traffic"); - - // Clean up before creating log file - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root)?; - - let log_file = std::env::var("TESTSERVER_LOG_FILE") - .unwrap_or_else(|_| root.join("testserver.log").to_string_lossy().into_owned()); - let log_file_rust_log = std::env::var("TESTSERVER_LOG_FILE_RUST_LOG").ok(); - - let _logging = init_logging(InitLoggingOptions { - default_rust_log_value: Some("info"), - log_file: Some(&log_file), - log_file_rust_log: Some(log_file_rust_log.as_deref().unwrap_or("debug")), - })?; - - info!("logging to file: {}", log_file); - - TestHarness { - torrents: create_torrents(&root.join("torrents"), 10).await?, - td: root, - } - .run_forever() - .await?; - Ok(()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/ubuntu.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/ubuntu.rs deleted file mode 100644 index ebc6c3cf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/examples/ubuntu.rs +++ /dev/null @@ -1,73 +0,0 @@ -// For production-grade code look at rtbit::main(), which does the same but has more options. -// -// Usage: -// cargo run --release --example ubuntu /tmp/ubuntu/ - -use std::time::Duration; - -use anyhow::Context; -use librtbit::{AddTorrent, AddTorrentOptions, AddTorrentResponse, Session}; -use tracing::info; - -// This is ubuntu-21.04-live-server-amd64.iso.torrent -// You can also pass filenames and URLs to add_torrent(). -const MAGNET_LINK: &str = "magnet:?xt=urn:btih:cab507494d02ebb1178b38f2e9d7be299c86b862"; - -#[tokio::main] -async fn main() -> Result<(), anyhow::Error> { - // Output logs to console. - match std::env::var("RUST_LOG") { - Ok(_) => {} - Err(_) => unsafe { std::env::set_var("RUST_LOG", "info") }, - } - tracing_subscriber::fmt::init(); - - let output_dir = std::env::args() - .nth(1) - .expect("the first argument should be the output directory"); - - // Create the session - let session = Session::new(output_dir.into()) - .await - .context("error creating session")?; - - // Add the torrent to the session - let handle = match session - .add_torrent( - AddTorrent::from_url(MAGNET_LINK), - Some(AddTorrentOptions { - // Allow writing on top of existing files. - overwrite: true, - ..Default::default() - }), - ) - .await - .context("error adding torrent")? - { - AddTorrentResponse::Added(_, handle) => handle, - // For a brand new session other variants won't happen. - _ => unreachable!(), - }; - - handle.with_metadata(|r| { - info!("Details: {:?}", &r.info); - })?; - - // Print stats periodically. - tokio::spawn({ - let handle = handle.clone(); - async move { - loop { - tokio::time::sleep(Duration::from_secs(1)).await; - let stats = handle.stats(); - info!("{stats:}"); - } - } - }); - - // Wait until the download is completed - handle.wait_until_completed().await?; - info!("torrent downloaded"); - - Ok(()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/test/extended-handshake.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/test/extended-handshake.bin deleted file mode 100644 index dda66003..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/test/extended-handshake.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/test/udp-tracker-announce-response.bin b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/test/udp-tracker-announce-response.bin deleted file mode 100644 index 4b1bc3aa..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/test/udp-tracker-announce-response.bin and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/ubuntu-21.04-desktop-amd64.iso.torrent b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/ubuntu-21.04-desktop-amd64.iso.torrent deleted file mode 100644 index ba26dc9e..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/ubuntu-21.04-desktop-amd64.iso.torrent and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/ubuntu-21.04-live-server-amd64.iso.torrent b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/ubuntu-21.04-live-server-amd64.iso.torrent deleted file mode 100644 index 0e13b404..00000000 Binary files a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/resources/ubuntu-21.04-live-server-amd64.iso.torrent and /dev/null differ diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/api.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/api.rs deleted file mode 100644 index c5c0b8d7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/api.rs +++ /dev/null @@ -1,732 +0,0 @@ -use std::{ - collections::{HashMap, HashSet}, - marker::PhantomData, - net::SocketAddr, - path::PathBuf, - str::FromStr, - sync::Arc, -}; - -use anyhow::Context; -use buffers::ByteBufOwned; -use dht::{DhtStats, Id20}; -use http::StatusCode; -use librtbit_core::torrent_metainfo::{FileDetailsAttrs, ValidatedTorrentMetaV1Info}; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc::UnboundedSender; - -use crate::{ - WithStatus, WithStatusError, - api_error::ApiError, - category::TorrentCategory, - session::{ - AddTorrent, AddTorrentOptions, AddTorrentResponse, ListOnlyResponse, Session, TorrentId, - }, - session_stats::snapshot::SessionStatsSnapshot, - torrent_state::{ - FileStream, ManagedTorrentHandle, - peer::stats::snapshot::{PeerStatsFilter, PeerStatsSnapshot}, - }, - type_aliases::BF, -}; - -#[cfg(feature = "tracing-subscriber-utils")] -use crate::tracing_subscriber_config_utils::LineBroadcast; -#[cfg(feature = "tracing-subscriber-utils")] -use futures::Stream; -#[cfg(feature = "tracing-subscriber-utils")] -use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; - -pub use crate::torrent_state::stats::{LiveStats, TorrentStats}; - -pub type Result = std::result::Result; - -/// Library API for use in different web frameworks. -/// Contains all methods you might want to expose with (de)serializable inputs/outputs. -#[derive(Clone)] -pub struct Api { - session: Arc, - rust_log_reload_tx: Option>, - #[cfg(feature = "tracing-subscriber-utils")] - line_broadcast: Option, -} - -#[derive(Debug, Clone, Copy)] -pub enum TorrentIdOrHash { - Id(TorrentId), - Hash(Id20), -} - -impl Serialize for TorrentIdOrHash { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - match self { - TorrentIdOrHash::Id(id) => id.serialize(serializer), - TorrentIdOrHash::Hash(h) => h.as_string().serialize(serializer), - } - } -} - -impl<'de> Deserialize<'de> for TorrentIdOrHash { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - #[derive(Default)] - struct V<'de> { - p: PhantomData<&'de ()>, - } - - macro_rules! visit_int { - ($v:expr) => {{ - let tid: TorrentId = $v.try_into().map_err(|e| E::custom(format!("{e:#}")))?; - Ok(TorrentIdOrHash::from(tid)) - }}; - } - - impl<'de> serde::de::Visitor<'de> for V<'de> { - type Value = TorrentIdOrHash; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("integer or 40 byte info hash") - } - - fn visit_i64(self, v: i64) -> std::result::Result - where - E: serde::de::Error, - { - visit_int!(v) - } - - fn visit_i128(self, v: i128) -> std::result::Result - where - E: serde::de::Error, - { - visit_int!(v) - } - - fn visit_u128(self, v: u128) -> std::result::Result - where - E: serde::de::Error, - { - visit_int!(v) - } - - fn visit_u64(self, v: u64) -> std::result::Result - where - E: serde::de::Error, - { - visit_int!(v) - } - - fn visit_str(self, v: &str) -> std::result::Result - where - E: serde::de::Error, - { - TorrentIdOrHash::parse(v).map_err(|e| { - E::custom(format!( - "expected integer or 40 byte info hash, couldn't parse string: {e:#}" - )) - }) - } - } - - deserializer.deserialize_any(V::default()) - } -} - -impl std::fmt::Display for TorrentIdOrHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TorrentIdOrHash::Id(id) => write!(f, "{id}"), - TorrentIdOrHash::Hash(h) => write!(f, "{h:?}"), - } - } -} - -impl From for TorrentIdOrHash { - fn from(value: TorrentId) -> Self { - TorrentIdOrHash::Id(value) - } -} - -impl From for TorrentIdOrHash { - fn from(value: Id20) -> Self { - TorrentIdOrHash::Hash(value) - } -} - -impl<'a> TryFrom<&'a str> for TorrentIdOrHash { - type Error = anyhow::Error; - - fn try_from(value: &'a str) -> std::result::Result { - Self::parse(value) - } -} - -impl TorrentIdOrHash { - pub fn parse(s: &str) -> anyhow::Result { - if s.len() == 40 { - let id = Id20::from_str(s)?; - return Ok(id.into()); - } - let id: TorrentId = s.parse()?; - Ok(id.into()) - } -} - -#[derive(Deserialize, Default)] -#[cfg_attr(feature = "swagger", derive(utoipa::IntoParams))] -pub struct ApiTorrentListOpts { - #[serde(default)] - pub with_stats: bool, - /// Offset for pagination (skip this many torrents). Default: 0. - pub offset: Option, - /// Maximum number of torrents to return. Default: all. - pub limit: Option, -} - -impl Api { - pub fn new( - session: Arc, - rust_log_reload_tx: Option>, - #[cfg(feature = "tracing-subscriber-utils")] line_broadcast: Option, - ) -> Self { - Self { - session, - rust_log_reload_tx, - #[cfg(feature = "tracing-subscriber-utils")] - line_broadcast, - } - } - - pub fn session(&self) -> &Arc { - &self.session - } - - pub fn mgr_handle(&self, idx: TorrentIdOrHash) -> Result { - self.session - .get(idx) - .ok_or(ApiError::torrent_not_found(idx)) - } - - pub fn api_torrent_list(&self) -> TorrentListResponse { - self.api_torrent_list_ext(ApiTorrentListOpts { - with_stats: false, - ..Default::default() - }) - } - - pub fn api_torrent_list_ext(&self, opts: ApiTorrentListOpts) -> TorrentListResponse { - let offset = opts.offset.unwrap_or(0); - let limit = opts.limit; - self.session.with_torrents(|torrents| { - let all: Vec<_> = torrents.collect(); - let total = all.len(); - let iter = all.into_iter().skip(offset); - let iter: Box> = match limit { - Some(limit) => Box::new(iter.take(limit)), - None => Box::new(iter), - }; - let items = iter - .map(|(id, mgr)| { - let total_pieces = mgr - .metadata - .load() - .as_ref() - .map(|m| m.info.lengths().total_pieces()) - .unwrap_or(0); - let mut r = TorrentDetailsResponse { - id: Some(id), - info_hash: mgr.shared().info_hash.as_string(), - name: mgr.name(), - output_folder: mgr - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), - total_pieces, - - // These will be filled in /details and /stats endpoints - files: None, - stats: None, - category: mgr.shared().category.read().clone(), - }; - if opts.with_stats { - r.stats = Some(mgr.stats()); - } - r - }) - .collect(); - TorrentListResponse { - torrents: items, - total, - } - }) - } - - pub fn api_torrent_details(&self, idx: TorrentIdOrHash) -> Result { - let handle = self.mgr_handle(idx)?; - let info_hash = handle.shared().info_hash; - let only_files = handle.only_files(); - let category = handle.shared().category.read().clone(); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned() - .to_string(); - make_torrent_details( - Some(handle.id()), - &info_hash, - handle.metadata.load().as_ref().map(|r| &r.info), - handle.name().as_deref(), - only_files.as_deref(), - output_folder, - category, - ) - } - - pub fn api_session_stats(&self) -> SessionStatsSnapshot { - self.session().stats_snapshot() - } - - pub fn torrent_file_mime_type( - &self, - idx: TorrentIdOrHash, - file_idx: usize, - ) -> Result<&'static str> { - let handle = self.mgr_handle(idx)?; - handle.with_metadata(|r| torrent_file_mime_type(&r.info, file_idx))? - } - - pub fn api_peer_stats( - &self, - idx: TorrentIdOrHash, - filter: PeerStatsFilter, - ) -> Result { - let handle = self.mgr_handle(idx)?; - Ok(handle - .live() - .with_status_error( - StatusCode::PRECONDITION_FAILED, - crate::Error::TorrentIsNotLive, - )? - .per_peer_stats_snapshot(filter)) - } - - pub async fn api_torrent_action_pause( - &self, - idx: TorrentIdOrHash, - ) -> Result { - let handle = self.mgr_handle(idx)?; - self.session() - .pause(&handle) - .await - .with_status(StatusCode::BAD_REQUEST)?; - Ok(Default::default()) - } - - pub async fn api_torrent_action_start( - &self, - idx: TorrentIdOrHash, - ) -> Result { - let handle = self.mgr_handle(idx)?; - self.session - .unpause(&handle) - .await - .with_status(StatusCode::BAD_REQUEST)?; - Ok(Default::default()) - } - - pub async fn api_torrent_action_forget( - &self, - idx: TorrentIdOrHash, - ) -> Result { - self.session - .delete(idx, false) - .await - .context("error forgetting torrent")?; - Ok(Default::default()) - } - - pub async fn api_torrent_action_delete( - &self, - idx: TorrentIdOrHash, - ) -> Result { - self.session - .delete(idx, true) - .await - .context("error deleting torrent with files")?; - Ok(Default::default()) - } - - pub async fn api_torrent_action_update_only_files( - &self, - idx: TorrentIdOrHash, - only_files: &HashSet, - ) -> Result { - let handle = self.mgr_handle(idx)?; - self.session - .update_only_files(&handle, only_files) - .await - .context("error updating only_files")?; - Ok(Default::default()) - } - - pub fn api_set_rust_log(&self, new_value: String) -> Result { - let tx = self - .rust_log_reload_tx - .as_ref() - .context("rust_log_reload_tx was not set")?; - tx.send(new_value) - .context("noone is listening to RUST_LOG changes")?; - Ok(Default::default()) - } - - #[cfg(feature = "tracing-subscriber-utils")] - pub fn api_log_lines_stream( - &self, - ) -> Result< - impl Stream> - + Send - + Sync - + 'static, - > { - Ok(self - .line_broadcast - .as_ref() - .map(|sender| BroadcastStream::new(sender.subscribe())) - .context("line_rx wasn't set")?) - } - - pub async fn api_add_torrent( - &self, - add: AddTorrent<'_>, - opts: Option, - ) -> Result { - let response = match self - .session - .add_torrent(add, opts) - .await - .context("error adding torrent") - .with_status(StatusCode::BAD_REQUEST)? - { - AddTorrentResponse::AlreadyManaged(id, handle) => { - let details = make_torrent_details( - Some(id), - &handle.info_hash(), - handle.metadata.load().as_ref().map(|r| &r.info), - handle.name().as_deref(), - handle.only_files().as_deref(), - handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), - handle.shared().category.read().clone(), - ) - .context("error making torrent details")?; - ApiAddTorrentResponse { - id: Some(id), - details, - seen_peers: None, - output_folder: handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), - } - } - AddTorrentResponse::ListOnly(ListOnlyResponse { - info_hash, - info, - only_files, - seen_peers, - output_folder, - .. - }) => ApiAddTorrentResponse { - id: None, - output_folder: output_folder.to_string_lossy().into_owned(), - seen_peers: Some(seen_peers), - details: make_torrent_details( - None, - &info_hash, - Some(&info), - None, - only_files.as_deref(), - output_folder.to_string_lossy().into_owned().to_string(), - None, - ) - .context("error making torrent details")?, - }, - AddTorrentResponse::Added(id, handle) => { - let details = make_torrent_details( - Some(id), - &handle.info_hash(), - handle.metadata.load().as_ref().map(|r| &r.info), - handle.name().as_deref(), - handle.only_files().as_deref(), - handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), - handle.shared().category.read().clone(), - ) - .context("error making torrent details")?; - ApiAddTorrentResponse { - id: Some(id), - details, - seen_peers: None, - output_folder: handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), - } - } - }; - Ok(response) - } - - pub fn api_completed_folder(&self) -> Option { - self.session - .completed_folder() - .map(|p| p.to_string_lossy().into_owned()) - } - - pub fn api_output_folder(&self) -> String { - self.session.output_folder().to_string_lossy().into_owned() - } - - pub fn api_set_output_folder(&self, folder: String) { - self.session.set_output_folder(PathBuf::from(folder)); - } - - pub fn api_set_completed_folder(&self, folder: Option) { - self.session.set_completed_folder(folder.map(PathBuf::from)); - } - - pub fn api_dht_stats(&self) -> Result { - self.session - .get_dht() - .as_ref() - .map(|d| d.stats()) - .ok_or(ApiError::dht_disabled()) - } - - pub fn api_dht_table(&self) -> Result> { - let dht = self.session.get_dht().ok_or(ApiError::dht_disabled())?; - Ok(dht.with_routing_tables(|v4, v6| { - #[derive(Serialize)] - struct Tables { - v4: T, - v6: T, - } - Tables { - v4: v4.clone(), - v6: v6.clone(), - } - })) - } - - pub fn api_stats_v0(&self, idx: TorrentIdOrHash) -> Result { - let mgr = self.mgr_handle(idx)?; - let live = mgr.live().context("torrent not live")?; - Ok(LiveStats::from(&*live)) - } - - pub fn api_stats_v1(&self, idx: TorrentIdOrHash) -> Result { - let mgr = self.mgr_handle(idx)?; - Ok(mgr.stats()) - } - - pub fn api_dump_haves(&self, idx: TorrentIdOrHash) -> Result<(BF, u32)> { - let mgr = self.mgr_handle(idx)?; - Ok(mgr.with_chunk_tracker(|chunks| { - let bf = BF::from_bitslice(chunks.get_have_pieces().as_slice()); - let len = chunks.get_lengths().total_pieces(); - (bf, len) - })?) - } - - pub async fn api_stream(&self, idx: TorrentIdOrHash, file_id: usize) -> Result { - let mgr = self.mgr_handle(idx)?; - Ok(mgr.stream(file_id).await?) - } - - pub fn api_list_categories(&self) -> HashMap { - self.session.categories().list() - } - - pub async fn api_create_or_edit_category( - &self, - name: String, - save_path: Option, - ) -> Result { - let mgr = self.session.categories(); - if mgr.exists(&name) { - mgr.edit(&name, save_path) - .context("error editing category")?; - } else { - mgr.create(name, save_path) - .context("error creating category")?; - } - self.session - .persist_categories() - .await - .context("error persisting categories")?; - Ok(Default::default()) - } - - pub async fn api_remove_category(&self, name: &str) -> Result { - self.session - .categories() - .remove(name) - .context("error removing category")?; - self.session - .persist_categories() - .await - .context("error persisting categories")?; - Ok(Default::default()) - } - - pub async fn api_set_torrent_category( - &self, - idx: TorrentIdOrHash, - category: Option, - ) -> Result { - self.session - .set_torrent_category(idx, category) - .context("error setting torrent category")?; - // Persist the torrent metadata to save the category - let handle = self.mgr_handle(idx)?; - self.session.try_update_persistence_metadata(&handle).await; - Ok(Default::default()) - } -} - -#[derive(Serialize)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct TorrentListResponse { - pub torrents: Vec, - /// Total number of torrents (before pagination). - pub total: usize, -} - -#[derive(Serialize, Deserialize)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct TorrentDetailsResponseFile { - pub name: String, - pub components: Vec, - pub length: u64, - pub included: bool, - #[cfg_attr(feature = "swagger", schema(value_type = Object))] - pub attributes: FileDetailsAttrs, -} - -#[derive(Default, Serialize)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct EmptyJsonResponse {} - -#[derive(Serialize, Deserialize)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct TorrentDetailsResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub info_hash: String, - pub name: Option, - pub output_folder: String, - - #[serde(default)] - pub total_pieces: u32, - - #[serde(skip_serializing_if = "Option::is_none")] - pub files: Option>, - #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] - #[cfg_attr(feature = "swagger", schema(value_type = Object))] - pub stats: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub category: Option, -} - -#[derive(Serialize, Deserialize)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct ApiAddTorrentResponse { - pub id: Option, - pub details: TorrentDetailsResponse, - pub output_folder: String, - #[cfg_attr(feature = "swagger", schema(value_type = Option>))] - pub seen_peers: Option>, -} - -fn make_torrent_details( - id: Option, - info_hash: &Id20, - info: Option<&ValidatedTorrentMetaV1Info>, - name: Option<&str>, - only_files: Option<&[usize]>, - output_folder: String, - category: Option, -) -> Result { - let files = match info { - Some(info) => info - .iter_file_details() - .enumerate() - .map(|(idx, d)| { - let name = d.filename.to_string(); - let components = d.filename.to_vec(); - let included = only_files.map(|o| o.contains(&idx)).unwrap_or(true); - TorrentDetailsResponseFile { - name, - components, - length: d.len, - included, - attributes: d.attrs(), - } - }) - .collect(), - None => Default::default(), - }; - let total_pieces = info.map(|i| i.lengths().total_pieces()).unwrap_or(0); - Ok(TorrentDetailsResponse { - id, - info_hash: info_hash.as_string(), - name: name - .map(|s| s.to_owned()) - .or_else(|| info.and_then(|i| i.name().map(|n| n.into_owned()))), - files: Some(files), - output_folder, - total_pieces, - stats: None, - category, - }) -} - -fn torrent_file_mime_type( - info: &ValidatedTorrentMetaV1Info, - file_idx: usize, -) -> Result<&'static str> { - Ok(info - .iter_file_details() - .nth(file_idx) - .and_then(|d| { - d.filename - .iter_components() - .last() - .and_then(|s| mime_guess::from_path(&*s).first_raw()) - }) - .ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - "cannot determine mime type for file", - ))?) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/api_error.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/api_error.rs deleted file mode 100644 index d87e775e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/api_error.rs +++ /dev/null @@ -1,219 +0,0 @@ -#[cfg(feature = "http-api")] -use axum::response::{IntoResponse, Response}; -use http::StatusCode; -use serde::{Serialize, Serializer}; - -use crate::api::TorrentIdOrHash; - -// Convenience error type. -#[derive(Debug)] -pub struct ApiError { - status: Option, - kind: ApiErrorKind, -} - -pub trait WithStatus { - fn with_status(self, status: StatusCode) -> Result; -} - -pub trait WithStatusError { - fn with_status_error>( - self, - status: StatusCode, - err: E, - ) -> Result; -} - -impl WithStatusError for Option { - fn with_status_error>( - self, - status: StatusCode, - err: E, - ) -> Result { - self.ok_or(ApiError { - status: Some(status), - kind: err.into(), - }) - } -} - -impl WithStatusError for Result { - fn with_status_error>( - self, - status: StatusCode, - err: E, - ) -> Result { - self.map_err(|_| ApiError::from((status, err.into()))) - } -} - -impl WithStatus for Result -where - ApiErrorKind: From, -{ - fn with_status(self, status: StatusCode) -> Result { - self.map_err(|e| ApiError::from((status, ApiErrorKind::from(e)))) - } -} - -impl ApiError { - pub const fn torrent_not_found(torrent_id: TorrentIdOrHash) -> Self { - Self { - status: Some(StatusCode::NOT_FOUND), - kind: ApiErrorKind::TorrentNotFound(torrent_id), - } - } - - #[allow(dead_code)] - pub fn not_implemented(msg: &'static str) -> Self { - Self { - status: Some(StatusCode::INTERNAL_SERVER_ERROR), - kind: ApiErrorKind::Text(msg), - } - } - - pub const fn dht_disabled() -> Self { - Self { - status: Some(StatusCode::NOT_FOUND), - kind: ApiErrorKind::DhtDisabled, - } - } - - pub const fn unauthorized() -> Self { - Self { - status: Some(StatusCode::UNAUTHORIZED), - kind: ApiErrorKind::Unauthorized, - } - } - - pub fn status(&self) -> StatusCode { - self.status.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) - } -} - -#[derive(thiserror::Error, Debug)] -pub enum ApiErrorKind { - #[error("torrent not found {0}")] - TorrentNotFound(TorrentIdOrHash), - #[error("DHT is disabled")] - DhtDisabled, - #[error("unauthorized")] - Unauthorized, - #[error("{0}")] - Text(&'static str), - - // TODO: consider just boxing all other errors into anyhow - #[error(transparent)] - OtherAnyhow(#[from] anyhow::Error), - #[error(transparent)] - OtherCore(#[from] librtbit_core::Error), - #[error(transparent)] - OtherError(#[from] crate::Error), -} - -impl From<&'static str> for ApiErrorKind { - fn from(value: &'static str) -> Self { - Self::Text(value) - } -} - -impl Serialize for ApiError { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - #[derive(Serialize, Default)] - struct SerializedError<'a> { - error_kind: &'a str, - human_readable: String, - status: u16, - status_text: String, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, - } - let mut serr: SerializedError = SerializedError { - error_kind: match self.kind { - ApiErrorKind::TorrentNotFound(_) => "torrent_not_found", - ApiErrorKind::DhtDisabled => "dht_disabled", - ApiErrorKind::Unauthorized => "unauthorized", - ApiErrorKind::OtherAnyhow(_) => "internal_error", - ApiErrorKind::OtherError(_) => "internal_error", - ApiErrorKind::OtherCore(_) => "internal_error", - ApiErrorKind::Text(_) => "internal_error", - }, - human_readable: format!("{self}"), - status: self.status().as_u16(), - status_text: self.status().to_string(), - ..Default::default() - }; - if let ApiErrorKind::TorrentNotFound(id) = &self.kind { - serr.id = Some(*id) - } - serr.serialize(serializer) - } -} - -impl From for ApiError { - fn from(value: anyhow::Error) -> Self { - let status = value.downcast_ref::().and_then(|e| e.status); - Self { - status, - kind: ApiErrorKind::OtherAnyhow(value), - } - } -} - -impl From for ApiError { - fn from(e: crate::Error) -> Self { - Self { - status: Some(StatusCode::INTERNAL_SERVER_ERROR), - kind: ApiErrorKind::OtherError(e), - } - } -} - -impl From for ApiError { - fn from(e: librtbit_core::Error) -> Self { - Self { - status: Some(StatusCode::INTERNAL_SERVER_ERROR), - kind: ApiErrorKind::OtherCore(e), - } - } -} - -impl From<(StatusCode, E)> for ApiError -where - ApiErrorKind: From, -{ - fn from(value: (StatusCode, E)) -> Self { - Self { - status: Some(value.0), - kind: value.1.into(), - } - } -} - -impl std::error::Error for ApiError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match &self.kind { - ApiErrorKind::OtherAnyhow(err) => Some(err.as_ref()), - ApiErrorKind::OtherError(err) => Some(err), - _ => None, - } - } -} - -impl std::fmt::Display for ApiError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:#}", self.kind) - } -} - -#[cfg(feature = "http-api")] -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let mut response = axum::Json(&self).into_response(); - *response.status_mut() = self.status(); - response - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/bitv.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/bitv.rs deleted file mode 100644 index 8933ee75..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/bitv.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::{io::Write, path::PathBuf}; - -use anyhow::Context; -use bitvec::{boxed::BitBox, order::Msb0, slice::BitSlice, vec::BitVec}; -use tracing::debug_span; - -use crate::spawn_utils::BlockingSpawner; - -/// Magic bytes identifying the new bitv format with CRC32 checksum. -const BITV_MAGIC: [u8; 4] = [0x52, 0x42, 0x56, 0x02]; // "RBV\x02" -const BITV_HEADER_LEN: usize = 8; // 4 magic + 4 CRC32 - -/// Wrap a bitfield payload with a header containing magic bytes and CRC32 checksum. -pub fn bitv_with_header(payload: &[u8]) -> Vec { - let crc = crc32fast::hash(payload); - let mut buf = Vec::with_capacity(BITV_HEADER_LEN + payload.len()); - buf.extend_from_slice(&BITV_MAGIC); - buf.extend_from_slice(&crc.to_le_bytes()); - buf.extend_from_slice(payload); - buf -} - -/// Result of reading a bitv file. -pub struct BitvReadResult { - /// The bitfield payload bytes. - pub payload: Vec, - /// Whether a CRC32 checksum was present and verified. - pub checksum_verified: bool, - /// Whether the file used the new format (has magic header). - pub has_header: bool, -} - -/// Parse a bitv file buffer, handling both old (raw) and new (header+CRC) formats. -pub fn read_bitv_file(buf: &[u8]) -> BitvReadResult { - if buf.len() >= BITV_HEADER_LEN && buf[..4] == BITV_MAGIC { - let stored_crc = u32::from_le_bytes(buf[4..8].try_into().unwrap()); - let payload = &buf[BITV_HEADER_LEN..]; - let computed_crc = crc32fast::hash(payload); - BitvReadResult { - payload: payload.to_vec(), - checksum_verified: stored_crc == computed_crc, - has_header: true, - } - } else { - // Old format: entire buffer is the raw bitfield payload - BitvReadResult { - payload: buf.to_vec(), - checksum_verified: false, - has_header: false, - } - } -} - -pub trait BitV: Send + Sync { - fn as_slice(&self) -> &BitSlice; - fn as_slice_mut(&mut self) -> &mut BitSlice; - fn into_dyn(self) -> Box; - fn as_bytes(&self) -> &[u8]; - fn flush(&mut self, flush_async: bool) -> anyhow::Result<()>; -} - -pub type BoxBitV = Box; - -struct DiskFlushRequest { - snapshot: BitBox, -} - -pub struct DiskBackedBitV { - bv: BitBox, - flush_tx: tokio::sync::mpsc::UnboundedSender, -} - -impl Drop for DiskBackedBitV { - fn drop(&mut self) { - if self - .flush_tx - .send(DiskFlushRequest { - snapshot: self.bv.clone(), - }) - .is_err() - { - tracing::warn!("error flushing bitv on drop: flusher task is dead") - } - } -} - -// NOTE on mmap. rtbit used it for a while, but it has issues on slow disks. -// We want writes to bitv to be instant in RAM. However when disk is slow, occasionally -// the writes stall which blocks the executor. -// Thus this separate "thread" of flushing was implemented. -impl DiskBackedBitV { - pub async fn new(filename: PathBuf, spawner: BlockingSpawner) -> anyhow::Result { - let raw_buf = tokio::fs::read(&filename) - .await - .with_context(|| format!("error reading {filename:?}"))?; - - let result = read_bitv_file(&raw_buf); - - // New format with CRC mismatch = definite corruption - if result.has_header && !result.checksum_verified { - tracing::warn!(?filename, "bitv file CRC32 mismatch, treating as corrupt"); - anyhow::bail!("bitv file CRC32 mismatch"); - } - - let bv = BitVec::from_vec(result.payload).into_boxed_bitslice(); - - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); - let flush_filename = filename.clone(); - librtbit_core::spawn_utils::spawn( - debug_span!("diskbitv-flusher", ?filename), - format!("DiskBackedBitV::flusher {filename:?}"), - async move { - let filename = flush_filename; - loop { - let Some(mut req) = rx.recv().await else { - break; - }; - while let Ok(r) = rx.try_recv() { - req = r; - } - - let tmp_path = filename.with_extension("bitv.tmp"); - let tmp_path_clone = tmp_path.clone(); - let filename_clone = filename.clone(); - - if let Err(e) = spawner - .block_in_place_with_semaphore(|| { - let data = bitv_with_header(req.snapshot.as_raw_slice()); - let mut tmp_file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp_path_clone)?; - tmp_file.write_all(&data)?; - tmp_file.sync_all()?; - drop(tmp_file); - std::fs::rename(&tmp_path_clone, &filename_clone)?; - Ok::<_, anyhow::Error>(()) - }) - .await - { - tracing::error!(?filename, "error writing bitv atomically: {e:#}"); - // Best-effort cleanup of temp file - let _ = tokio::fs::remove_file(&tmp_path).await; - if let Err(e) = tokio::fs::remove_file(&filename).await { - tracing::error!(?filename, "error removing bitv: {e:#}"); - } - break; - } - } - - Ok::<_, anyhow::Error>(()) - }, - ); - Ok(Self { bv, flush_tx: tx }) - } -} - -#[async_trait::async_trait] -impl BitV for BitBox { - fn as_slice(&self) -> &BitSlice { - self.as_bitslice() - } - - fn as_slice_mut(&mut self) -> &mut BitSlice { - self.as_mut_bitslice() - } - - fn as_bytes(&self) -> &[u8] { - self.as_raw_slice() - } - - fn flush(&mut self, _flush_async: bool) -> anyhow::Result<()> { - Ok(()) - } - - fn into_dyn(self) -> Box { - Box::new(self) - } -} - -impl BitV for DiskBackedBitV { - fn as_slice(&self) -> &BitSlice { - self.bv.as_bitslice() - } - - fn as_slice_mut(&mut self) -> &mut BitSlice { - self.bv.as_mut_bitslice() - } - - fn as_bytes(&self) -> &[u8] { - self.bv.as_raw_slice() - } - - fn flush(&mut self, _flush_async: bool) -> anyhow::Result<()> { - let req = DiskFlushRequest { - snapshot: self.bv.clone(), - }; - self.flush_tx.send(req).context("flusher task is dead") - } - - fn into_dyn(self) -> Box { - Box::new(self) - } -} - -impl BitV for Box { - fn as_slice(&self) -> &BitSlice { - (**self).as_slice() - } - - fn as_slice_mut(&mut self) -> &mut BitSlice { - (**self).as_slice_mut() - } - - fn as_bytes(&self) -> &[u8] { - (**self).as_bytes() - } - - fn flush(&mut self, flush_async: bool) -> anyhow::Result<()> { - (**self).flush(flush_async) - } - - fn into_dyn(self) -> Box { - self - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/bitv_factory.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/bitv_factory.rs deleted file mode 100644 index 21b8724e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/bitv_factory.rs +++ /dev/null @@ -1,33 +0,0 @@ -use crate::{api::TorrentIdOrHash, bitv::BitV, type_aliases::BF}; - -#[async_trait::async_trait] -pub trait BitVFactory: Send + Sync { - async fn load(&self, id: TorrentIdOrHash) -> anyhow::Result>>; - async fn clear(&self, id: TorrentIdOrHash) -> anyhow::Result<()>; - async fn store_initial_check( - &self, - id: TorrentIdOrHash, - b: BF, - ) -> anyhow::Result>; -} - -pub struct NonPersistentBitVFactory {} - -#[async_trait::async_trait] -impl BitVFactory for NonPersistentBitVFactory { - async fn load(&self, _: TorrentIdOrHash) -> anyhow::Result>> { - Ok(None) - } - - async fn clear(&self, _id: TorrentIdOrHash) -> anyhow::Result<()> { - Ok(()) - } - - async fn store_initial_check( - &self, - _id: TorrentIdOrHash, - b: BF, - ) -> anyhow::Result> { - Ok(Box::new(b)) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/category.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/category.rs deleted file mode 100644 index 2dc0a033..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/category.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use anyhow::bail; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TorrentCategory { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub save_path: Option, -} - -#[derive(Debug)] -pub struct CategoryManager { - categories: RwLock>, -} - -impl Default for CategoryManager { - fn default() -> Self { - Self::new() - } -} - -impl CategoryManager { - pub fn new() -> Self { - Self { - categories: RwLock::new(HashMap::new()), - } - } - - pub fn from_categories(cats: HashMap) -> Self { - Self { - categories: RwLock::new(cats), - } - } - - pub fn list(&self) -> HashMap { - self.categories.read().clone() - } - - pub fn create(&self, name: String, save_path: Option) -> anyhow::Result<()> { - let mut cats = self.categories.write(); - if cats.contains_key(&name) { - bail!("category '{}' already exists", name); - } - cats.insert(name.clone(), TorrentCategory { name, save_path }); - Ok(()) - } - - pub fn edit(&self, name: &str, save_path: Option) -> anyhow::Result<()> { - let mut cats = self.categories.write(); - let cat = cats.get_mut(name); - match cat { - Some(cat) => { - cat.save_path = save_path; - Ok(()) - } - None => bail!("category '{}' does not exist", name), - } - } - - pub fn remove(&self, name: &str) -> anyhow::Result<()> { - let mut cats = self.categories.write(); - if cats.remove(name).is_none() { - bail!("category '{}' does not exist", name); - } - Ok(()) - } - - pub fn get(&self, name: &str) -> Option { - self.categories.read().get(name).cloned() - } - - pub fn exists(&self, name: &str) -> bool { - self.categories.read().contains_key(name) - } - - pub fn snapshot(&self) -> HashMap { - self.categories.read().clone() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/chunk_tracker.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/chunk_tracker.rs deleted file mode 100644 index 0da19e5b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/chunk_tracker.rs +++ /dev/null @@ -1,1003 +0,0 @@ -use std::collections::HashSet; - -use anyhow::Context; -use buffers::ByteBuf; -use librtbit_core::lengths::{ChunkInfo, Lengths, ValidPieceIndex}; -use peer_binary_protocol::Piece; -use tracing::{debug, trace}; - -use crate::{ - bitv::{BitV, BoxBitV}, - file_info::FileInfo, - type_aliases::{BF, BS, FileInfos, FilePriorities}, -}; - -pub struct ChunkTracker { - // This forms the basis of a "queue" to pull from. - // It's set to 1 if we need a piece, but the moment we start requesting a peer, - // it's set to 0. - // - // Initially this is the opposite of "have", until we start making requests. - // An in-flight request is not in in the queue, and not in "have". - // - // needed initial value = selected & !have - queue_pieces: BF, - - // This has a bit set per each chunk (block) that we have written to the output file. - // It doesn't mean it's valid yet. Used to track how much is left in each piece. - chunk_status: BF, - - // These are the pieces that we actually have, fully checked and downloaded. - have: BoxBitV, - - // The pieces that the user selected. This doesn't change unless update_only_files - // was called. - selected: BF, - - // How many bytes do we have per each file. - per_file_bytes: Vec, - - lengths: Lengths, - - // Quick to retrieve stats, that MUST be in sync with the BFs - // above (have/selected). - hns: HaveNeededSelected, -} - -#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] -pub struct HaveNeededSelected { - // How many bytes we have downloaded and verified. - pub have_bytes: u64, - // How many bytes do we need to download for selected to be - // a subset of have. - pub needed_bytes: u64, - // How many bytes the user selected (by picking files). - pub selected_bytes: u64, -} - -impl HaveNeededSelected { - pub const fn progress(&self) -> u64 { - self.selected_bytes - self.needed_bytes - } - - pub const fn total(&self) -> u64 { - self.selected_bytes - } - - pub const fn finished(&self) -> bool { - self.needed_bytes == 0 - } -} - -// Compute the have-status of chunks. -// -// Save as "have_pieces", but there's one bit per chunk (not per piece). -fn compute_chunk_have_status(lengths: &Lengths, have_pieces: &BS) -> anyhow::Result { - if have_pieces.len() < lengths.total_pieces() as usize { - anyhow::bail!( - "bug: have_pieces.len() < lengths.total_pieces(); {} < {}", - have_pieces.len(), - lengths.total_pieces() - ); - } - - let required_size = lengths.chunk_bitfield_bytes(); - let vec = vec![0u8; required_size]; - let mut chunk_bf = BF::from_boxed_slice(vec.into_boxed_slice()); - - for piece in lengths.iter_piece_infos() { - let chunks = lengths.chunks_per_piece(piece.piece_index) as usize; - let offset = (lengths.default_chunks_per_piece() * piece.piece_index.get()) as usize; - let range = offset..(offset + chunks); - if have_pieces[piece.piece_index.get() as usize] { - chunk_bf - .get_mut(range.clone()) - .with_context(|| { - format!("bug in bitvec: error getting range {range:?} from chunk_bf") - })? - .fill(true); - } - } - Ok(chunk_bf) -} - -fn compute_queued_pieces_unchecked(have_pieces: &BS, selected_pieces: &BS) -> BF { - // it's needed ONLY if it's selected and we don't have it. - use core::ops::BitAnd; - use core::ops::Not; - - have_pieces - .to_bitvec() - .not() - .bitand(selected_pieces) - .into_boxed_bitslice() -} - -fn compute_queued_pieces(have_pieces: &BS, selected_pieces: &BS) -> anyhow::Result { - if have_pieces.len() != selected_pieces.len() { - anyhow::bail!( - "have_pieces.len() != selected_pieces.len(), {} != {}", - have_pieces.len(), - selected_pieces.len() - ); - } - - Ok(compute_queued_pieces_unchecked( - have_pieces, - selected_pieces, - )) -} - -pub(crate) fn compute_selected_pieces( - lengths: &Lengths, - only_files_is_empty_or_contains: impl Fn(usize) -> bool, - file_infos: &FileInfos, -) -> BF { - let mut bf = BF::from_boxed_slice(vec![0u8; lengths.piece_bitfield_bytes()].into_boxed_slice()); - for (_, fi) in file_infos - .iter() - .enumerate() - .filter(|(_, fi)| !fi.attrs.padding) - .filter(|(id, _)| only_files_is_empty_or_contains(*id)) - { - if let Some(r) = bf.get_mut(fi.piece_range_usize()) { - r.fill(true); - } - } - bf -} - -#[derive(Debug)] -pub enum ChunkMarkingResult { - PreviouslyCompleted, - NotCompleted, - Completed, -} - -impl ChunkTracker { - pub fn new( - // Have pieces are the ones we have already downloaded and verified. - have_pieces: BoxBitV, - // Selected pieces are the ones the user has selected - selected_pieces: BF, - lengths: Lengths, - file_infos: &FileInfos, - ) -> anyhow::Result { - let needed_pieces = compute_queued_pieces(have_pieces.as_slice(), &selected_pieces) - .context("error computing needed pieces")?; - - // TODO: ideally this needs to be a list based on needed files, e.g. - // last needed piece for each file. But let's keep simple for now. - - let mut ct = Self { - chunk_status: compute_chunk_have_status(&lengths, have_pieces.as_slice()) - .context("error computing chunk status")?, - queue_pieces: needed_pieces, - selected: selected_pieces, - lengths, - have: have_pieces, - hns: HaveNeededSelected::default(), - per_file_bytes: vec![0; file_infos.len()], - }; - ct.recalculate_per_file_bytes(file_infos); - ct.hns = ct.calc_hns(); - Ok(ct) - } - - fn recalculate_per_file_bytes(&mut self, file_infos: &FileInfos) { - for (slot, fi) in self.per_file_bytes.iter_mut().zip(file_infos.iter()) { - *slot = fi - .piece_range - .clone() - .filter(|p| self.have.as_slice()[*p as usize]) - .map(|id| { - self.lengths - .size_of_piece_in_file(id, fi.offset_in_torrent, fi.len) - }) - .sum(); - } - } - - pub fn get_lengths(&self) -> &Lengths { - &self.lengths - } - - pub fn get_have_pieces(&self) -> &dyn BitV { - &*self.have - } - - pub fn get_have_pieces_mut(&mut self) -> &mut dyn BitV { - &mut *self.have - } - - pub fn reserve_needed_piece(&mut self, index: ValidPieceIndex) { - self.queue_pieces.set(index.get() as usize, false) - } - - pub fn get_hns(&self) -> &HaveNeededSelected { - &self.hns - } - - fn calc_hns(&self) -> HaveNeededSelected { - let mut hns = HaveNeededSelected::default(); - for piece in self.lengths.iter_piece_infos() { - let id = piece.piece_index.get() as usize; - let len = piece.len as u64; - let is_have = self.have.as_slice()[id]; - let is_selected = self.selected[id]; - let is_needed = is_selected && !is_have; - hns.have_bytes += len * (is_have as u64); - hns.selected_bytes += len * (is_selected as u64); - hns.needed_bytes += len * (is_needed as u64); - } - hns - } - - pub(crate) fn iter_queued_pieces<'a>( - &'a self, - file_priorities: &'a FilePriorities, - file_infos: &'a FileInfos, - ) -> impl Iterator + 'a { - file_priorities - .iter() - .filter_map(|p| Some((*p, file_infos.get(*p)?))) - .filter(|(id, f)| self.per_file_bytes[*id] != f.len) - .flat_map(|(_id, f)| f.iter_piece_priorities()) - .filter(|id| self.queue_pieces[*id]) - .filter_map(|id| id.try_into().ok()) - .filter_map(|id| self.lengths.validate_piece_index(id)) - } - - pub(crate) fn is_piece_have(&self, id: ValidPieceIndex) -> bool { - self.have.as_slice()[id.get() as usize] - } - - pub fn mark_piece_broken_if_not_have(&mut self, index: ValidPieceIndex) { - if self - .have - .as_slice() - .get(index.get() as usize) - .map(|r| *r) - .unwrap_or_default() - { - return; - } - debug!("marking piece={} as broken", index); - self.queue_pieces.set(index.get() as usize, true); - if let Some(s) = self.chunk_status.get_mut(self.lengths.chunk_range(index)) { - s.fill(false); - } - } - - pub fn mark_piece_downloaded(&mut self, idx: ValidPieceIndex) { - let id = idx.get() as usize; - if !self.have.as_slice()[id] { - self.have.as_slice_mut().set(id, true); - let len = self.lengths.piece_length(idx) as u64; - self.hns.have_bytes += len; - if self.selected[id] { - self.hns.needed_bytes -= len; - } - } - } - - pub fn is_chunk_ready_to_upload(&self, chunk: &ChunkInfo) -> bool { - self.have - .as_slice() - .get(chunk.piece_index.get() as usize) - .map(|b| *b) - .unwrap_or(false) - } - - pub fn get_remaining_bytes(&self) -> u64 { - self.hns.needed_bytes - } - - // return true if the whole piece is marked downloaded - pub fn mark_chunk_downloaded( - &mut self, - piece: &Piece>, - ) -> Option { - let chunk_info = self.lengths.chunk_info_from_received_data( - self.lengths.validate_piece_index(piece.index)?, - piece.begin, - piece.len().try_into().unwrap(), - )?; - let chunk_range = self.lengths.chunk_range(chunk_info.piece_index); - let chunk_range = self.chunk_status.get_mut(chunk_range).unwrap(); - if chunk_range.all() { - return Some(ChunkMarkingResult::PreviouslyCompleted); - } - chunk_range.set(chunk_info.chunk_index as usize, true); - trace!( - "piece={}, chunk_info={:?}, bits={:?}", - piece.index, chunk_info, chunk_range, - ); - - if chunk_range.all() { - return Some(ChunkMarkingResult::Completed); - } - Some(ChunkMarkingResult::NotCompleted) - } - - pub fn update_only_files( - &mut self, - file_infos: &FileInfos, - new_only_files: &HashSet, - ) -> anyhow::Result { - let selected = compute_selected_pieces( - &self.lengths, - |idx| new_only_files.contains(&idx), - file_infos, - ); - let prev_selected = std::mem::replace(&mut self.selected, selected); - - // prev_selected=false and selected=true and have=false: requeue the piece - { - let mut b = BF::from_boxed_slice( - vec![0u8; self.lengths.piece_bitfield_bytes()].into_boxed_slice(), - ); - for idx in self - .selected - .iter_ones() - .filter(|idx| !prev_selected[*idx] && !self.have.as_slice()[*idx]) - { - b.set(idx, true); - } - - for idx in b.iter_ones() { - if let Ok(idx_u32) = u32::try_from(idx) - && let Some(validated) = self.lengths.validate_piece_index(idx_u32) - { - self.mark_piece_broken_if_not_have(validated); - } - } - } - - // selected=false, have=false: don't need the piece, and don't have it - cancel downloading it - { - // TODO: is there a better way to write this? - // self.queue_pieces &= self.have | self.selected; - let mut have_or_selected: BF = self.selected.clone(); - have_or_selected |= self.have.as_slice(); - self.queue_pieces &= have_or_selected; - } - - self.hns = self.calc_hns(); - Ok(self.hns) - } - - pub(crate) fn get_selected_pieces(&self) -> &BF { - &self.selected - } - - pub fn is_file_finished(&self, file_info: &FileInfo) -> bool { - self.have - .as_slice() - .get(file_info.piece_range_usize()) - .map(|r| r.all()) - .unwrap_or(true) - } - - pub(crate) fn is_finished(&self) -> bool { - self.get_hns().finished() - } - - pub fn per_file_have_bytes(&self) -> &[u64] { - &self.per_file_bytes - } - - // Returns remaining bytes - pub fn update_file_have_on_piece_completed( - &mut self, - piece_id: ValidPieceIndex, - file_id: usize, - file_info: &FileInfo, - ) -> u64 { - let diff_have = self.lengths.size_of_piece_in_file( - piece_id.get(), - file_info.offset_in_torrent, - file_info.len, - ); - self.per_file_bytes[file_id] += diff_have; - file_info.len.saturating_sub(self.per_file_bytes[file_id]) - } -} - -#[cfg(test)] -mod tests { - use librtbit_core::{constants::CHUNK_SIZE, lengths::Lengths}; - use std::collections::HashSet; - - use crate::{ - bitv::BitV, chunk_tracker::HaveNeededSelected, file_info::FileInfo, type_aliases::BF, - }; - - use super::{ChunkTracker, compute_chunk_have_status}; - - #[test] - fn test_compute_chunk_status() { - // Create the most obnoxious lengths, and ensure it doesn't break in that case. - let piece_length = CHUNK_SIZE * 2 + 1; - let l = Lengths::new(piece_length as u64 * 2 + 1, piece_length).unwrap(); - - assert_eq!(l.total_pieces(), 3); - assert_eq!(l.default_chunks_per_piece(), 3); - assert_eq!(l.total_chunks(), 7); - - { - let mut have_pieces = - BF::from_boxed_slice(vec![u8::MAX; l.piece_bitfield_bytes()].into_boxed_slice()); - have_pieces.set(0, false); - - let chunks = compute_chunk_have_status(&l, &have_pieces).unwrap(); - assert!(!chunks[0]); - assert!(!chunks[1]); - assert!(!chunks[2]); - assert!(chunks[3]); - assert!(chunks[4]); - assert!(chunks[5]); - assert!(chunks[6]); - } - - { - let mut have_pieces = - BF::from_boxed_slice(vec![u8::MAX; l.piece_bitfield_bytes()].into_boxed_slice()); - have_pieces.set(1, false); - - let chunks = compute_chunk_have_status(&l, &have_pieces).unwrap(); - dbg!(&chunks); - assert!(chunks[0]); - assert!(chunks[1]); - assert!(chunks[2]); - assert!(!chunks[3]); - assert!(!chunks[4]); - assert!(!chunks[5]); - assert!(chunks[6]); - } - - { - let mut have_pieces = - BF::from_boxed_slice(vec![u8::MAX; l.piece_bitfield_bytes()].into_boxed_slice()); - have_pieces.set(2, false); - - let chunks = compute_chunk_have_status(&l, &have_pieces).unwrap(); - dbg!(&chunks); - assert!(chunks[0]); - assert!(chunks[1]); - assert!(chunks[2]); - assert!(chunks[3]); - assert!(chunks[4]); - assert!(chunks[5]); - assert!(!chunks[6]); - } - - { - // A more reasonable case. - let piece_length = CHUNK_SIZE * 2; - let l = Lengths::new(piece_length as u64 * 2 + 1, piece_length).unwrap(); - - assert_eq!(l.total_pieces(), 3); - assert_eq!(l.default_chunks_per_piece(), 2); - assert_eq!(l.total_chunks(), 5); - - { - let mut have_pieces = BF::from_boxed_slice( - vec![u8::MAX; l.piece_bitfield_bytes()].into_boxed_slice(), - ); - have_pieces.set(1, false); - - let chunks = compute_chunk_have_status(&l, &have_pieces).unwrap(); - dbg!(&chunks); - assert!(chunks[0]); - assert!(chunks[1]); - assert!(!chunks[2]); - assert!(!chunks[3]); - assert!(chunks[4]); - } - - { - let mut have_pieces = BF::from_boxed_slice( - vec![u8::MAX; l.piece_bitfield_bytes()].into_boxed_slice(), - ); - have_pieces.set(2, false); - - let chunks = compute_chunk_have_status(&l, &have_pieces).unwrap(); - dbg!(&chunks); - assert!(chunks[0]); - assert!(chunks[1]); - assert!(chunks[2]); - assert!(chunks[3]); - assert!(!chunks[4]); - } - } - } - - #[test] - fn test_chunk_status_all_complete() { - // All pieces downloaded. - let piece_length = CHUNK_SIZE * 2; - let l = Lengths::new(piece_length as u64 * 3, piece_length).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let mut have = BF::from_boxed_slice(vec![0xFFu8; bf_len].into_boxed_slice()); - // Ensure only valid bits are set (trim extra bits). - for i in l.total_pieces() as usize..have.len() { - have.set(i, false); - } - - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - for i in 0..l.total_pieces() as usize { - selected.set(i, true); - } - - let file_infos = vec![FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - piece_range: 0..l.total_pieces(), - len: piece_length as u64 * 3, - attrs: Default::default(), - }]; - - let ct = ChunkTracker::new(have.into_dyn(), selected, l, &file_infos).unwrap(); - assert!(ct.is_finished()); - assert_eq!(ct.get_remaining_bytes(), 0); - assert_eq!(ct.get_hns().have_bytes, piece_length as u64 * 3); - } - - #[test] - fn test_chunk_status_partial() { - // Only first piece downloaded out of 3. - let piece_length = CHUNK_SIZE * 2; - let l = Lengths::new(piece_length as u64 * 3, piece_length).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let mut have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - have.set(0, true); // Only piece 0 downloaded. - - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - for i in 0..3usize { - selected.set(i, true); - } - - let file_infos = vec![FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - piece_range: 0..3, - len: piece_length as u64 * 3, - attrs: Default::default(), - }]; - - let ct = ChunkTracker::new(have.into_dyn(), selected, l, &file_infos).unwrap(); - assert!(!ct.is_finished()); - assert_eq!(ct.get_hns().have_bytes, piece_length as u64); - assert_eq!(ct.get_hns().needed_bytes, piece_length as u64 * 2); - assert_eq!(ct.get_hns().selected_bytes, piece_length as u64 * 3); - } - - #[test] - fn test_update_only_files_expand() { - // Start with only file 0 selected, then expand to include file 1. - let piece_len = CHUNK_SIZE * 2; - let total_len = piece_len as u64 * 4; - let l = Lengths::new(total_len, piece_len).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - - // Initially select only pieces 0..2 (file 0). - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - selected.set(0, true); - selected.set(1, true); - - let all_files = vec![ - FileInfo { - relative_filename: "file0.dat".into(), - offset_in_torrent: 0, - piece_range: 0..2, - len: piece_len as u64 * 2, - attrs: Default::default(), - }, - FileInfo { - relative_filename: "file1.dat".into(), - offset_in_torrent: piece_len as u64 * 2, - piece_range: 2..4, - len: piece_len as u64 * 2, - attrs: Default::default(), - }, - ]; - - let mut ct = ChunkTracker::new(have.into_dyn(), selected, l, &all_files).unwrap(); - - // Initially: only file 0 selected. - assert_eq!(ct.get_hns().selected_bytes, piece_len as u64 * 2); - assert_eq!(ct.get_hns().needed_bytes, piece_len as u64 * 2); - - // Expand to include both files. - let hns = ct - .update_only_files(&all_files, &std::collections::HashSet::from_iter([0, 1])) - .unwrap(); - assert_eq!(hns.selected_bytes, total_len); - assert_eq!(hns.needed_bytes, total_len); - } - - #[test] - fn test_update_only_files_shrink() { - // Start with both files selected, then shrink to only file 0. - let piece_len = CHUNK_SIZE * 2; - let total_len = piece_len as u64 * 4; - let l = Lengths::new(total_len, piece_len).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - for i in 0..4usize { - selected.set(i, true); - } - - let all_files = vec![ - FileInfo { - relative_filename: "file0.dat".into(), - offset_in_torrent: 0, - piece_range: 0..2, - len: piece_len as u64 * 2, - attrs: Default::default(), - }, - FileInfo { - relative_filename: "file1.dat".into(), - offset_in_torrent: piece_len as u64 * 2, - piece_range: 2..4, - len: piece_len as u64 * 2, - attrs: Default::default(), - }, - ]; - - let mut ct = ChunkTracker::new(have.into_dyn(), selected, l, &all_files).unwrap(); - - // Initially: both files selected. - assert_eq!(ct.get_hns().selected_bytes, total_len); - - // Shrink to only file 0. - let hns = ct - .update_only_files(&all_files, &std::collections::HashSet::from_iter([0])) - .unwrap(); - assert_eq!(hns.selected_bytes, piece_len as u64 * 2); - assert_eq!(hns.needed_bytes, piece_len as u64 * 2); - - // Pieces 2 and 3 should not be in queue. - assert!(!ct.queue_pieces[2]); - assert!(!ct.queue_pieces[3]); - // Pieces 0 and 1 should be in queue (needed, not have). - assert!(ct.queue_pieces[0]); - assert!(ct.queue_pieces[1]); - } - - #[test] - fn test_mark_piece_downloaded_updates_hns() { - let piece_length = CHUNK_SIZE * 2; - let l = Lengths::new(piece_length as u64 * 3, piece_length).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - for i in 0..3usize { - selected.set(i, true); - } - - let file_infos = vec![FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - piece_range: 0..3, - len: piece_length as u64 * 3, - attrs: Default::default(), - }]; - - let mut ct = ChunkTracker::new(have.into_dyn(), selected, l, &file_infos).unwrap(); - assert_eq!(ct.get_hns().have_bytes, 0); - - let piece0 = l.validate_piece_index(0).unwrap(); - ct.mark_piece_downloaded(piece0); - - assert_eq!(ct.get_hns().have_bytes, piece_length as u64); - assert_eq!(ct.get_hns().needed_bytes, piece_length as u64 * 2); - assert!(ct.is_piece_have(piece0)); - } - - #[test] - fn test_mark_piece_broken_requeues() { - let piece_length = CHUNK_SIZE * 2; - let l = Lengths::new(piece_length as u64 * 3, piece_length).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - for i in 0..3usize { - selected.set(i, true); - } - - let file_infos = vec![FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - piece_range: 0..3, - len: piece_length as u64 * 3, - attrs: Default::default(), - }]; - - let mut ct = ChunkTracker::new(have.into_dyn(), selected, l, &file_infos).unwrap(); - - let piece0 = l.validate_piece_index(0).unwrap(); - - // Reserve piece (remove from queue). - ct.reserve_needed_piece(piece0); - assert!(!ct.queue_pieces[0]); - - // Mark broken should re-add to queue. - ct.mark_piece_broken_if_not_have(piece0); - assert!(ct.queue_pieces[0]); - } - - #[test] - fn test_is_chunk_ready_to_upload() { - let piece_length = CHUNK_SIZE * 2; - let l = Lengths::new(piece_length as u64 * 2, piece_length).unwrap(); - - let bf_len = l.piece_bitfield_bytes(); - let mut have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - have.set(0, true); // Have piece 0. - - let selected = BF::from_boxed_slice(vec![0xFFu8; bf_len].into_boxed_slice()); - - let file_infos = vec![FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - piece_range: 0..2, - len: piece_length as u64 * 2, - attrs: Default::default(), - }]; - - let ct = ChunkTracker::new(have.into_dyn(), selected, l, &file_infos).unwrap(); - - let chunk0 = librtbit_core::lengths::ChunkInfo { - piece_index: l.validate_piece_index(0).unwrap(), - chunk_index: 0, - absolute_index: 0, - offset: 0, - size: CHUNK_SIZE, - }; - let chunk1 = librtbit_core::lengths::ChunkInfo { - piece_index: l.validate_piece_index(1).unwrap(), - chunk_index: 0, - absolute_index: l.default_chunks_per_piece(), - offset: 0, - size: CHUNK_SIZE, - }; - - assert!(ct.is_chunk_ready_to_upload(&chunk0)); - assert!(!ct.is_chunk_ready_to_upload(&chunk1)); - } - - #[test] - fn test_update_only_files() { - let piece_len = CHUNK_SIZE * 2 + 1; - let total_len = piece_len as u64 * 2 + 1; - let l = Lengths::new(total_len, piece_len).unwrap(); - assert_eq!(l.total_pieces(), 3); - assert_eq!(l.total_chunks(), 7); - - let all_files = vec![ - FileInfo { - relative_filename: "0".into(), - offset_in_torrent: 0, - piece_range: 0..1, - len: piece_len as u64, - attrs: Default::default(), - }, - FileInfo { - relative_filename: "1".into(), - offset_in_torrent: piece_len as u64, - piece_range: 1..2, - len: 1, - attrs: Default::default(), - }, - FileInfo { - relative_filename: "2".into(), - offset_in_torrent: piece_len as u64 + 1, - piece_range: 1..1, - len: 0, - attrs: Default::default(), - }, - FileInfo { - relative_filename: "3".into(), - offset_in_torrent: piece_len as u64 + 1, - piece_range: 1..3, - len: piece_len as u64, - attrs: Default::default(), - }, - ]; - - let bf_len = l.piece_bitfield_bytes(); - let initial_have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - let initial_selected = { - let mut bf = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - bf.get_mut(0..3).unwrap().fill(true); - bf - }; - - // Initially, we need all files and all pieces. - let mut ct = ChunkTracker::new( - initial_have.clone().into_dyn(), - initial_selected.clone(), - l, - &Default::default(), - ) - .unwrap(); - - // Select all file, no changes. - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([0, 1, 2, 3])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: total_len, - needed_bytes: total_len, - } - ); - assert_eq!(ct.have.as_slice(), initial_have.as_bitslice()); - assert_eq!(ct.queue_pieces, initial_selected); - - // Select only the first file. - println!("Select only the first file."); - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([0])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: all_files[0].len, - needed_bytes: all_files[0].len, - } - ); - assert!(ct.queue_pieces[0]); - assert!(!ct.queue_pieces[1]); - assert!(!ct.queue_pieces[2]); - - // Select only the second file. - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([1])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: piece_len as u64, - needed_bytes: piece_len as u64, - } - ); - assert!(!ct.queue_pieces[0]); - assert!(ct.queue_pieces[1]); - assert!(!ct.queue_pieces[2]); - - // Select only the third file (zero sized one!). - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([2])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: 0, - needed_bytes: 0, - } - ); - assert!(!ct.queue_pieces[0]); - assert!(!ct.queue_pieces[1]); - assert!(!ct.queue_pieces[2]); - - // Select only the fourth file. - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([3])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: (piece_len + 1) as u64, - needed_bytes: (piece_len + 1) as u64, - } - ); - assert!(!ct.queue_pieces[0]); - assert!(ct.queue_pieces[1]); - assert!(ct.queue_pieces[2]); - - // Select first and last file - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([0, 3])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: all_files[0].len + all_files[3].len + 1, - needed_bytes: all_files[0].len + all_files[3].len + 1, - } - ); - assert!(ct.queue_pieces[0]); - assert!(ct.queue_pieces[1]); - assert!(ct.queue_pieces[2]); - - // Select all files - assert_eq!( - ct.update_only_files(&all_files, &HashSet::from_iter([0, 1, 2, 3])) - .unwrap(), - HaveNeededSelected { - have_bytes: 0, - selected_bytes: total_len, - needed_bytes: total_len - } - ); - assert!(ct.queue_pieces[0]); - assert!(ct.queue_pieces[1]); - assert!(ct.queue_pieces[2]); - } - - #[test] - fn test_piece_index_large_values() { - // Verify that u32::try_from correctly handles usize values that exceed u32::MAX. - // On 64-bit platforms, usize can hold values > u32::MAX. The old code used - // `idx as u32` which would silently truncate. The new code uses try_from - // which returns Err for out-of-range values, and we simply skip them. - - // Values within u32 range should convert fine - let idx: usize = 0; - assert!(u32::try_from(idx).is_ok()); - - let idx: usize = u32::MAX as usize; - assert!(u32::try_from(idx).is_ok()); - assert_eq!(u32::try_from(idx).unwrap(), u32::MAX); - - // Values exceeding u32::MAX should fail gracefully (on 64-bit) - #[cfg(target_pointer_width = "64")] - { - let idx: usize = u32::MAX as usize + 1; - assert!(u32::try_from(idx).is_err()); - - let idx: usize = usize::MAX; - assert!(u32::try_from(idx).is_err()); - } - } - - #[test] - fn test_eta_overflow_safety() { - // Simulate the ETA calculation pattern used in qbit_compat.rs. - // The pattern: i64::try_from(remaining / dl_speed).unwrap_or(8640000i64) - // Previously used `as i64` which could overflow. - - // Very large remaining / very small speed => value exceeds i64::MAX - let remaining: u64 = u64::MAX; - let dl_speed: u64 = 1; - let eta_secs = remaining / dl_speed; - let eta = i64::try_from(eta_secs).unwrap_or(8640000i64); - assert_eq!(eta, 8640000i64, "should clamp to fallback on overflow"); - - // Large but within i64 range - let remaining: u64 = 1_000_000_000_000; - let dl_speed: u64 = 100; - let eta_secs = remaining / dl_speed; - let eta = i64::try_from(eta_secs).unwrap_or(8640000i64); - assert_eq!( - eta, 10_000_000_000i64, - "should return exact ETA when it fits in i64" - ); - - // Zero speed: the calling code guards against this, but verify the fallback - let dl_speed: u64 = 0; - let eta = if dl_speed > 0 { - let remaining: u64 = 1_000_000; - let eta_secs = remaining / dl_speed; - i64::try_from(eta_secs).unwrap_or(8640000i64) - } else { - 8640000i64 - }; - assert_eq!(eta, 8640000i64, "zero speed should return 8640000"); - - // Timestamp cast: current unix timestamp fits in i64 - let now: u64 = i64::MAX as u64 + 1; - let result = i64::try_from(now).unwrap_or(i64::MAX); - assert_eq!(result, i64::MAX, "should clamp to i64::MAX on overflow"); - - let now: u64 = 1_700_000_000; - let result = i64::try_from(now).unwrap_or(i64::MAX); - assert_eq!(result, 1_700_000_000i64); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/create_torrent_file.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/create_torrent_file.rs deleted file mode 100644 index 89fabbfe..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/create_torrent_file.rs +++ /dev/null @@ -1,448 +0,0 @@ -use std::borrow::Cow; -use std::ffi::OsStr; -use std::io::{BufWriter, Read}; -use std::path::{Path, PathBuf}; - -use anyhow::Context; -use bencode::{WithRawBytes, bencode_serialize_to_writer}; -use buffers::ByteBufOwned; -use bytes::Bytes; -use librtbit_core::Id20; -use librtbit_core::magnet::Magnet; -use librtbit_core::torrent_metainfo::{TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned}; -use sha1w::ISha1; - -use crate::spawn_utils::BlockingSpawner; - -#[derive(Debug, Clone, Default)] -pub struct CreateTorrentOptions<'a> { - pub name: Option<&'a str>, - pub trackers: Vec, - pub piece_length: Option, -} - -fn walk_dir_find_paths(dir: &Path, out: &mut Vec>) -> anyhow::Result<()> { - out.extend( - walkdir::WalkDir::new(dir) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .map(|e| e.path().to_owned().into()), - ); - Ok(()) -} - -fn compute_info_hash(t: &TorrentMetaV1Info) -> anyhow::Result<(Id20, Bytes)> { - let mut writer = BufWriter::new(Vec::new()); - bencode_serialize_to_writer(t, &mut writer)?; - let bytes: Bytes = writer - .into_inner() - .map_err(|_| anyhow::anyhow!("into_inner errored"))? - .into(); - let hash = Id20::new({ - let mut h = sha1w::Sha1::new(); - h.update(&bytes); - h.finish() - }); - Ok((hash, bytes)) -} - -fn choose_piece_length(_input_files: &[Cow<'_, Path>]) -> u32 { - // TODO: make this smarter or smth - 2 * 1024 * 1024 -} - -fn osstr_to_bytes(o: &OsStr) -> Vec { - o.to_str().unwrap().to_owned().into_bytes() -} - -struct CreateTorrentRawResult { - info: TorrentMetaV1Info, - output_folder: PathBuf, -} - -async fn create_torrent_raw<'a>( - path: &'a Path, - options: CreateTorrentOptions<'a>, - spawner: &BlockingSpawner, -) -> anyhow::Result { - path.try_exists() - .with_context(|| format!("path {path:?} doesn't exist"))?; - let basename = path - .file_name() - .ok_or_else(|| anyhow::anyhow!("cannot determine basename of {path:?}"))?; - let is_dir = path.is_dir(); - let single_file_mode = !is_dir; - let name: ByteBufOwned = match options.name { - Some(name) => name.as_bytes().into(), - None => osstr_to_bytes(basename).into(), - }; - let output_folder: PathBuf; - - let mut input_files: Vec> = Default::default(); - if is_dir { - output_folder = path.to_owned(); - walk_dir_find_paths(path, &mut input_files) - .with_context(|| format!("error walking {path:?}"))?; - } else { - output_folder = path - .canonicalize()? - .parent() - .context("single file has no parent")? - .to_path_buf(); - input_files.push(Cow::Borrowed(path)); - } - - let piece_length = options - .piece_length - .unwrap_or_else(|| choose_piece_length(&input_files)); - - // Calculate hashes etc. - const READ_SIZE: u32 = 8192; // todo: twea - let mut read_buf = vec![0; READ_SIZE as usize]; - - let _permit = spawner.semaphore().acquire_owned().await?; - - let mut length = 0; - let mut remaining_piece_length = piece_length; - let mut piece_checksum = sha1w::Sha1::new(); - let mut piece_hashes = Vec::::new(); - let mut output_files: Vec> = Vec::new(); - - 'outer: for file in input_files { - let filename = &*file; - length = 0; - let mut fd = std::io::BufReader::new( - std::fs::File::open(&file).with_context(|| format!("error opening {filename:?}"))?, - ); - - loop { - let max_bytes_to_read = remaining_piece_length.min(READ_SIZE) as usize; - // NOTE: we can't use the semaphore as Sha1 isn't Send at least on OSX. - let size = spawner - .block_in_place(|| fd.read(&mut read_buf[..max_bytes_to_read])) - .with_context(|| format!("error reading {filename:?}"))?; - - // EOF: swap file - if size == 0 { - let filename = filename - .strip_prefix(path) - .context("internal error, can't strip prefix")?; - let path = filename - .components() - .map(|c| osstr_to_bytes(c.as_os_str()).into()) - .collect(); - output_files.push(TorrentMetaV1File { - length, - path, - attr: None, - sha1: None, - symlink_path: None, - }); - continue 'outer; - } - - length += size as u64; - piece_checksum.update(&read_buf[..size]); - - remaining_piece_length -= TryInto::::try_into(size)?; - if remaining_piece_length == 0 { - remaining_piece_length = piece_length; - piece_hashes.extend_from_slice(&piece_checksum.finish()); - piece_checksum = sha1w::Sha1::new(); - } - } - } - - if remaining_piece_length > 0 && length > 0 { - piece_hashes.extend_from_slice(&piece_checksum.finish()); - } - Ok(CreateTorrentRawResult { - info: TorrentMetaV1Info { - name: Some(name), - pieces: Some(piece_hashes.into()), - piece_length, - meta_version: None, - length: if single_file_mode { Some(length) } else { None }, - md5sum: None, - files: if single_file_mode { - None - } else { - Some(output_files) - }, - attr: None, - sha1: None, - symlink_path: None, - private: false, - }, - output_folder, - }) -} - -#[derive(Debug)] -pub struct CreateTorrentResult { - pub meta: TorrentMetaV1Owned, - pub output_folder: PathBuf, -} - -impl CreateTorrentResult { - pub fn as_info(&self) -> &TorrentMetaV1Owned { - &self.meta - } - - pub fn info_hash(&self) -> Id20 { - self.meta.info_hash - } - - pub fn as_magnet(&self) -> Magnet { - let trackers = self - .meta - .iter_announce() - .map(|i| std::str::from_utf8(i.as_ref()).unwrap().to_owned()) - .collect(); - Magnet::from_id20(self.info_hash(), trackers, None) - } - - pub fn as_bytes(&self) -> anyhow::Result { - let mut b = Vec::new(); - bencode_serialize_to_writer(&self.meta, &mut b).context("error serializing torrent")?; - Ok(b.into()) - } -} - -pub async fn create_torrent<'a>( - path: &'a Path, - options: CreateTorrentOptions<'a>, - spawner: &BlockingSpawner, -) -> anyhow::Result { - let trackers = options - .trackers - .iter() - .map(|t| ByteBufOwned::from(t.as_bytes())) - .collect(); - let res = create_torrent_raw(path, options, spawner).await?; - let (info_hash, bytes) = compute_info_hash(&res.info).context("error computing info hash")?; - Ok(CreateTorrentResult { - meta: TorrentMetaV1Owned { - announce: None, - announce_list: vec![trackers], - info: WithRawBytes { - data: res.info, - raw_bytes: ByteBufOwned(bytes), - }, - comment: None, - created_by: None, - encoding: Some(b"utf-8"[..].into()), - publisher: None, - publisher_url: None, - url_list: None, - creation_date: None, - info_hash, - }, - output_folder: res.output_folder, - }) -} - -#[cfg(test)] -mod tests { - use librtbit_core::torrent_metainfo::torrent_from_bytes; - - use crate::{create_torrent, spawn_utils::BlockingSpawner}; - - #[tokio::test] - async fn test_create_torrent() { - use crate::tests::test_util; - - let dir = test_util::create_default_random_dir_with_torrents( - 3, - 1000 * 1000, - Some("rtbit_test_create_torrent"), - ); - let torrent = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let bytes = torrent.as_bytes().unwrap(); - - let deserialized = torrent_from_bytes(&bytes).unwrap(); - assert_eq!(torrent.info_hash(), deserialized.info_hash); - } - - #[tokio::test] - async fn test_create_torrent_single_file() { - use super::CreateTorrentOptions; - use crate::tests::test_util; - - let dir = tempfile::tempdir().unwrap(); - let file_path = dir.path().join("single.bin"); - test_util::create_new_file_with_random_content(&file_path, 50_000); - - let torrent = create_torrent( - &file_path, - CreateTorrentOptions { - name: Some("test_single"), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await - .unwrap(); - - let info = &torrent.meta.info.data; - // Single file mode: length is set, files is None. - assert!(info.length.is_some()); - assert!(info.files.is_none()); - assert_eq!(info.length.unwrap(), 50_000); - assert_eq!( - info.name.as_ref().map(|n| n.as_ref()), - Some(b"test_single" as &[u8]) - ); - } - - #[tokio::test] - async fn test_create_torrent_multi_file() { - use crate::tests::test_util; - - let dir = tempfile::tempdir().unwrap(); - test_util::create_new_file_with_random_content(&dir.path().join("a.bin"), 30_000); - test_util::create_new_file_with_random_content(&dir.path().join("b.bin"), 40_000); - test_util::create_new_file_with_random_content(&dir.path().join("c.bin"), 50_000); - - let torrent = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let info = &torrent.meta.info.data; - // Multi-file mode: length is None, files is Some. - assert!(info.length.is_none()); - let files = info.files.as_ref().unwrap(); - assert_eq!(files.len(), 3); - - // Verify total file sizes sum correctly. - let total_size: u64 = files.iter().map(|f| f.length).sum(); - assert_eq!(total_size, 120_000); - } - - #[tokio::test] - async fn test_create_torrent_roundtrip() { - use crate::tests::test_util; - - let dir = test_util::create_default_random_dir_with_torrents( - 2, - 100_000, - Some("rtbit_test_roundtrip"), - ); - - let torrent = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let bytes = torrent.as_bytes().unwrap(); - let deserialized = torrent_from_bytes(&bytes).unwrap(); - - // Info hash must match. - assert_eq!(torrent.info_hash(), deserialized.info_hash); - - // Piece length must match. - assert_eq!( - torrent.meta.info.data.piece_length, - deserialized.info.data.piece_length - ); - - // Piece hashes must match in length. - assert_eq!( - torrent - .meta - .info - .data - .pieces - .as_ref() - .unwrap() - .as_ref() - .len(), - deserialized - .info - .data - .pieces - .as_ref() - .unwrap() - .as_ref() - .len() - ); - - // Name must roundtrip. - assert_eq!( - torrent.meta.info.data.name.as_ref().map(|n| n.as_ref()), - deserialized.info.data.name.as_ref().map(|n| n.as_ref()), - ); - } - - #[tokio::test] - async fn test_create_torrent_piece_hashes() { - use crate::tests::test_util; - - let dir = tempfile::tempdir().unwrap(); - // Small file, smaller than one piece (default 2MiB) - test_util::create_new_file_with_random_content(&dir.path().join("tiny.bin"), 1024); - - let torrent = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let info = &torrent.meta.info.data; - // Piece hashes should be a multiple of 20 bytes (SHA1 hash size). - let pieces = info.pieces.as_ref().unwrap(); - assert_eq!(pieces.as_ref().len() % 20, 0); - - let num_pieces = pieces.as_ref().len() / 20; - // For a 1024 byte file with 2MiB pieces, there should be exactly 1 piece. - assert_eq!(num_pieces, 1); - } - - #[tokio::test] - async fn test_create_torrent_with_trackers() { - use super::CreateTorrentOptions; - use crate::tests::test_util; - - let dir = tempfile::tempdir().unwrap(); - test_util::create_new_file_with_random_content(&dir.path().join("tracked.bin"), 5000); - - let torrent = create_torrent( - dir.path(), - CreateTorrentOptions { - trackers: vec!["http://tracker.example.com:8080/announce".to_string()], - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await - .unwrap(); - - // Verify the tracker URL is present in announce_list. - let tracker_urls: Vec<_> = torrent.meta.iter_announce().collect(); - assert!(!tracker_urls.is_empty()); - assert_eq!( - std::str::from_utf8(tracker_urls[0].as_ref()).unwrap(), - "http://tracker.example.com:8080/announce" - ); - } - - #[tokio::test] - async fn test_create_torrent_magnet() { - use crate::tests::test_util; - - let dir = tempfile::tempdir().unwrap(); - test_util::create_new_file_with_random_content(&dir.path().join("magnet_test.bin"), 5000); - - let torrent = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let magnet = torrent.as_magnet(); - let magnet_str = magnet.to_string(); - // Magnet link should start with magnet:? - assert!(magnet_str.starts_with("magnet:?")); - // Should contain the info hash - assert!(magnet_str.contains(&torrent.info_hash().as_string())); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/dht_utils.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/dht_utils.rs deleted file mode 100644 index 459c00c5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/dht_utils.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::{collections::HashSet, net::SocketAddr, sync::Arc}; - -use anyhow::Context; -use buffers::ByteBufOwned; -use futures::{Stream, StreamExt, stream::FuturesUnordered}; -use librtbit_core::torrent_metainfo::TorrentMetaV1Info; -use tracing::{Instrument, debug, debug_span}; - -use crate::{ - peer_connection::PeerConnectionOptions, peer_info_reader, spawn_utils::BlockingSpawner, - stream_connect::StreamConnector, -}; -use librtbit_core::hash_id::Id20; - -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] -pub enum ReadMetainfoResult { - Found { - info: TorrentMetaV1Info, - info_bytes: ByteBufOwned, - rx: Rx, - seen: HashSet, - }, - ChannelClosed { - #[allow(dead_code)] - seen: HashSet, - }, -} - -pub async fn read_metainfo_from_peer_receiver + Unpin>( - peer_id: Id20, - info_hash: Id20, - initial_addrs: Vec, - addrs_stream: A, - peer_connection_options: Option, - connector: Arc, -) -> ReadMetainfoResult { - let mut seen = HashSet::::new(); - let mut addrs = addrs_stream; - - let semaphore = tokio::sync::Semaphore::new(128); - - let read_info_guarded = |addr| { - let semaphore = &semaphore; - let connector = connector.clone(); - async move { - let token = semaphore.acquire().await?; - let ret = peer_info_reader::read_metainfo_from_peer( - addr, - peer_id, - info_hash, - peer_connection_options, - // This shouldn't be called anyway as we aren't reading/writing to disk, so it's - // ok not to use a shared one. - BlockingSpawner::new(1), - connector, - ) - .instrument(debug_span!("read_metainfo_from_peer", ?addr)) - .await - .with_context(|| format!("error reading metainfo from {addr}")); - drop(token); - ret - } - }; - - let mut unordered = FuturesUnordered::new(); - - for a in initial_addrs { - seen.insert(a); - unordered.push(read_info_guarded(a)); - } - - let mut addrs_completed = false; - - loop { - if addrs_completed && unordered.is_empty() { - return ReadMetainfoResult::ChannelClosed { seen }; - } - - tokio::select! { - done = unordered.next(), if !unordered.is_empty() => { - match done { - Some(Ok((info, info_bytes))) => return ReadMetainfoResult::Found { info, info_bytes, seen, rx: addrs }, - Some(Err(e)) => { - debug!("{:#}", e); - }, - None => unreachable!() - } - } - - next_addr = addrs.next(), if !addrs_completed => { - match next_addr { - Some(addr) => { - if seen.insert(addr) { - unordered.push(read_info_guarded(addr)); - } - continue; - }, - None => { - addrs_completed = true; - }, - } - } - }; - } -} - -#[cfg(test)] -mod tests { - use dht::{DhtBuilder, Id20}; - use librtbit_core::peer_id::generate_peer_id; - - use super::*; - use std::{ - str::FromStr, - sync::{Arc, Once}, - }; - - static LOG_INIT: Once = Once::new(); - - fn init_logging() { - #[allow(unused_must_use)] - LOG_INIT.call_once(|| { - // pretty_env_logger::try_init(); - }) - } - - #[tokio::test] - #[ignore] - async fn read_metainfo_from_dht() { - init_logging(); - - let info_hash = Id20::from_str("cab507494d02ebb1178b38f2e9d7be299c86b862").unwrap(); - let dht = DhtBuilder::new().await.unwrap(); - - let peer_rx = dht.get_peers(info_hash, None); - let peer_id = generate_peer_id(b"-xx1234-"); - match read_metainfo_from_peer_receiver( - peer_id, - info_hash, - Vec::new(), - peer_rx, - None, - Arc::new(StreamConnector::new(Default::default()).await.unwrap()), - ) - .await - { - ReadMetainfoResult::Found { info, .. } => dbg!(info), - ReadMetainfoResult::ChannelClosed { .. } => { - panic!("channel closed unexpectedly before metainfo was received") - } - }; - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/error.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/error.rs deleted file mode 100644 index 5f5e295f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/error.rs +++ /dev/null @@ -1,125 +0,0 @@ -use governor::InsufficientCapacity; -use peer_binary_protocol::MessageDeserializeError; -use tokio::sync::AcquireError; - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("error connecting through proxy: {0:#}")] - SocksConnect( - #[from] - #[source] - tokio_socks::Error, - ), - #[error("error connecting over TCP: {0:#}")] - TcpConnect(#[source] librqbit_dualstack_sockets::Error), - #[error("error connecting over uTP: {0:#}")] - UtpConnect(#[source] librqbit_utp::Error), - #[error("can't connect, all connection methods disabled")] - ConnectDisabled, - #[error("error connecting: TCP={tcp:#} uTP={utp:#}")] - Connect { - tcp: librqbit_dualstack_sockets::Error, - utp: librqbit_utp::Error, - }, - #[error("uTP disabled")] - UtpDisabled, - #[error("TCP connections disabled")] - TcpDisabled, - - #[error("wrong info hash")] - WrongInfoHash, - #[error("connecting to ourselves")] - ConnectingToOurselves, - - #[error("error writing handshake: {0:#}")] - WriteHandshake(#[source] std::io::Error), - #[error("error reading handshake: {0:#}")] - ReadHandshake(#[source] std::io::Error), - - #[error("error writing: {0:#}")] - Write(#[source] std::io::Error), - #[error("error reading: {0:#}")] - Read(#[source] std::io::Error), - - #[error("timeout {0}")] - Timeout(&'static str), - - #[error("peer disconnected while reading handshake")] - PeerDisconnectedReadingHandshake, - #[error("peer disconnected")] - PeerDisconnected, - - #[error(transparent)] - ProtoSerialize(#[from] peer_binary_protocol::SerializeError), - - #[error("error deserializing handshake: {0:#}")] - DeserializeHandshake(#[source] MessageDeserializeError), - #[error("error deserializing message: {0:#}")] - Deserialize( - #[from] - #[source] - MessageDeserializeError, - ), - - #[error(transparent)] - Anyhow(anyhow::Error), - - #[error("error reading chunk: {0:#}")] - ReadChunk(#[source] anyhow::Error), - - #[error("disconnect requested")] - Disconnect, - - #[error("disconnecting peer, reason: {0:#}")] - DisconnectWithSource(#[source] anyhow::Error), - - #[error("bug: make_contiguous() called on a contiguous buffer; start={start} len={len}")] - BugReadBufMakeContiguous { start: u16, len: u16 }, - - #[error("read buffer is full. need_additional_bytes={need_additional_bytes}")] - ReadBufFull { need_additional_bytes: u16 }, - - #[cfg(test)] - #[error("disconnected to simulate failure in tests")] - TestDisconnect, - - #[error("torrent is not live")] - TorrentIsNotLive, - - #[error("peer task is dead")] - PeerTaskDead, - - #[error("chunk tracker empty, torrent was paused")] - ChunkTrackerEmpty, - - #[error("rate limiting: insufficient capacity: {0:#}")] - RateLimitInsufficientCapacity( - #[from] - #[source] - InsufficientCapacity, - ), - - #[error("semaphore closed")] - SemaphoreAcquireError( - #[from] - #[source] - AcquireError, - ), - - #[error("bug: peer not found")] - BugPeerNotFound, - - #[error("bug: invalid peer state")] - BugInvalidPeerState, - - #[error("file is None, torrent was probably paused")] - FsFileIsNone, - - #[error("session is dead")] - SessionDestroyed, - - #[error(transparent)] - Core(#[from] librtbit_core::Error), -} - -pub type Result = core::result::Result; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/file_info.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/file_info.rs deleted file mode 100644 index 93391de1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/file_info.rs +++ /dev/null @@ -1,52 +0,0 @@ -use std::path::PathBuf; - -use librtbit_core::torrent_metainfo::FileDetailsAttrs; - -#[derive(Debug, Clone)] -pub struct FileInfo { - pub relative_filename: PathBuf, - pub offset_in_torrent: u64, - pub piece_range: std::ops::Range, - pub attrs: FileDetailsAttrs, - pub len: u64, -} - -// Iterate file pieces in the following order: first, last, everything else from start to end. -fn iter_piece_priorities(range: std::ops::Range) -> impl Iterator { - // First and last of each file first, then the rest of pieces in that file. - let r = range; - use std::iter::once; - - let first = once(r.start); - let last = once(r.start + r.len().overflowing_sub(1).0); // it's ok if it repeats, doesn't matter - let mid = r.clone().skip(1).take(r.len().overflowing_sub(2).0); - - // The take(r.len()) is to not yield start/end pieces in case of 0 and 1 lengths. - first.chain(last).chain(mid).take(r.len()) -} - -impl FileInfo { - pub fn piece_range_usize(&self) -> std::ops::Range { - self.piece_range.start as usize..self.piece_range.end as usize - } - - pub fn iter_piece_priorities(&self) -> impl Iterator { - iter_piece_priorities(self.piece_range_usize()) - } -} - -#[cfg(test)] -mod tests { - use super::iter_piece_priorities; - - #[test] - fn test_iter_piece_priorities() { - let it = |r: std::ops::Range| -> Vec { iter_piece_priorities(r).collect() }; - assert_eq!(it(0..0), Vec::::new()); - - assert_eq!(it(0..1), vec![0]); - assert_eq!(it(0..2), vec![0, 1]); - assert_eq!(it(0..3), vec![0, 2, 1]); - assert_eq!(it(0..4), vec![0, 3, 1, 2]); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/file_ops.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/file_ops.rs deleted file mode 100644 index 71c5a017..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/file_ops.rs +++ /dev/null @@ -1,376 +0,0 @@ -use std::{ - marker::PhantomData, - sync::atomic::{AtomicU64, Ordering}, -}; - -use anyhow::Context; -use buffers::{ByteBuf, ByteBufOwned}; -use librtbit_core::{ - lengths::{ChunkInfo, ValidPieceIndex}, - torrent_metainfo::ValidatedTorrentMetaV1Info, -}; -use peer_binary_protocol::{DoubleBufHelper, Piece}; -use sha1w::{ISha1, Sha1}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, trace, warn}; - -use crate::{ - file_info::FileInfo, - storage::TorrentStorage, - type_aliases::{BF, FileInfos, PeerHandle}, -}; - -pub fn update_hash_from_file( - file_id: usize, - file_info: &FileInfo, - mut pos: u64, - files: &dyn TorrentStorage, - hash: &mut Sha1, - buf: &mut [u8], - mut bytes_to_read: usize, -) -> anyhow::Result<()> { - let mut read = 0; - while bytes_to_read > 0 { - let chunk = std::cmp::min(buf.len(), bytes_to_read); - if file_info.attrs.padding { - buf[..chunk].fill(0); - } else { - files - .pread_exact(file_id, pos, &mut buf[..chunk]) - .with_context(|| { - format!("failed reading chunk of size {chunk}, read so far {read}") - })?; - } - bytes_to_read -= chunk; - read += chunk; - pos += chunk as u64; - hash.update(&buf[..chunk]); - } - Ok(()) -} - -pub(crate) struct FileOps<'a> { - torrent: &'a ValidatedTorrentMetaV1Info, - files: &'a dyn TorrentStorage, - file_infos: &'a FileInfos, - phantom_data: PhantomData, -} - -impl<'a> FileOps<'a> { - pub fn new( - torrent: &'a ValidatedTorrentMetaV1Info, - files: &'a dyn TorrentStorage, - file_infos: &'a FileInfos, - ) -> Self { - Self { - torrent, - files, - file_infos, - phantom_data: PhantomData, - } - } - - // Returns the bitvector with pieces we have. - pub fn initial_check( - &self, - progress: &AtomicU64, - cancellation_token: Option<&CancellationToken>, - ) -> anyhow::Result { - let mut have_pieces = - BF::from_boxed_slice(vec![0u8; self.torrent.lengths().piece_bitfield_bytes()].into()); - let mut piece_files = Vec::::new(); - - #[derive(Debug)] - struct CurrentFile<'a> { - index: usize, - fi: &'a FileInfo, - processed_bytes: u64, - is_broken: bool, - } - impl CurrentFile<'_> { - fn remaining(&self) -> u64 { - self.fi.len - self.processed_bytes - } - fn mark_processed_bytes(&mut self, bytes: u64) { - self.processed_bytes += bytes - } - } - let mut file_iterator = self - .file_infos - .iter() - .enumerate() - .map(|(idx, fi)| CurrentFile { - index: idx, - fi, - processed_bytes: 0, - is_broken: false, - }); - - let mut current_file = file_iterator.next().context("empty input file list")?; - - let mut read_buffer = vec![0u8; 65536]; - - for piece_info in self.torrent.lengths().iter_piece_infos() { - // Check for cancellation periodically (every piece). - if let Some(token) = cancellation_token - && token.is_cancelled() - { - anyhow::bail!("initial check cancelled"); - } - - piece_files.clear(); - let mut computed_hash = Sha1::new(); - let mut piece_remaining = piece_info.len as usize; - let mut some_files_broken = false; - progress.fetch_add(piece_info.len as u64, Ordering::Relaxed); - - while piece_remaining > 0 { - let mut to_read_in_file: usize = - std::cmp::min(current_file.remaining(), piece_remaining as u64) - .try_into() - .context("read size exceeds usize::MAX")?; - - // Keep changing the current file to next until we find a file that has greater than 0 length. - while to_read_in_file == 0 { - current_file = file_iterator.next().context("broken torrent metadata")?; - - to_read_in_file = - std::cmp::min(current_file.remaining(), piece_remaining as u64) - .try_into() - .context("read size exceeds usize::MAX")?; - } - - piece_files.push(current_file.index); - - let pos = current_file.processed_bytes; - piece_remaining -= to_read_in_file; - current_file.mark_processed_bytes(to_read_in_file as u64); - - if current_file.is_broken { - // no need to read. - continue; - } - - if let Err(err) = update_hash_from_file( - current_file.index, - current_file.fi, - pos, - self.files, - &mut computed_hash, - &mut read_buffer, - to_read_in_file, - ) { - debug!( - "error reading from file {} ({:?}) at {}: {:#}", - current_file.index, current_file.fi.relative_filename, pos, &err - ); - current_file.is_broken = true; - some_files_broken = true; - } - } - - if some_files_broken { - trace!( - "piece {} had errors, marking as needed", - piece_info.piece_index - ); - continue; - } - - if self - .torrent - .info() - .compare_hash(piece_info.piece_index.get(), computed_hash.finish()) - .context("bug: either torrent info broken or we have a bug - piece index invalid")? - { - have_pieces.set(piece_info.piece_index.get() as usize, true); - } - } - - Ok(have_pieces) - } - - pub fn check_piece(&self, piece_index: ValidPieceIndex) -> anyhow::Result { - if cfg!(feature = "_disable_disk_write_net_benchmark") { - return Ok(true); - } - - let mut h = Sha1::new(); - let piece_length = self.torrent.lengths().piece_length(piece_index); - let mut absolute_offset = self.torrent.lengths().piece_offset(piece_index); - let mut buf = vec![0u8; std::cmp::min(65536, piece_length as usize)]; - - let mut piece_remaining_bytes = piece_length as usize; - - for (file_idx, fi) in self.file_infos.iter().enumerate() { - let file_len = fi.len; - if absolute_offset > file_len { - absolute_offset -= file_len; - continue; - } - let file_remaining_len = file_len - absolute_offset; - - let to_read_in_file: usize = - std::cmp::min(file_remaining_len, piece_remaining_bytes as u64) - .try_into() - .context("read size exceeds usize::MAX")?; - trace!( - "piece={}, file_idx={}, seeking to {}", - piece_index, file_idx, absolute_offset, - ); - update_hash_from_file( - file_idx, - fi, - absolute_offset, - self.files, - &mut h, - &mut buf, - to_read_in_file, - ) - .with_context(|| { - format!( - "error reading {to_read_in_file} bytes, file_id: {file_idx} (\"{:?}\")", - fi.relative_filename - ) - })?; - - piece_remaining_bytes -= to_read_in_file; - - if piece_remaining_bytes == 0 { - break; - } - - absolute_offset = 0; - } - - match self - .torrent - .info() - .compare_hash(piece_index.get(), h.finish()) - { - Some(true) => { - trace!("piece={} hash matches", piece_index); - Ok(true) - } - Some(false) => { - let piece_length = self.torrent.lengths().piece_length(piece_index); - let absolute_offset = self.torrent.lengths().piece_offset(piece_index); - warn!( - piece_length, - absolute_offset, "the piece={} hash does not match", piece_index - ); - Ok(false) - } - None => { - // this is probably a bug? - warn!("compare_hash() did not find the piece"); - anyhow::bail!("compare_hash() did not find the piece"); - } - } - } - - pub fn read_chunk( - &self, - who_sent: PeerHandle, - chunk_info: &ChunkInfo, - result_buf: &mut [u8], - ) -> anyhow::Result<()> { - if result_buf.len() < chunk_info.size as usize { - anyhow::bail!("read_chunk(): not enough capacity in the provided buffer") - } - let mut absolute_offset = self.torrent.lengths().chunk_absolute_offset(chunk_info); - let mut buf = result_buf; - - for (file_idx, file_info) in self.file_infos.iter().enumerate() { - let file_len = file_info.len; - if absolute_offset > file_len { - absolute_offset -= file_len; - continue; - } - let file_remaining_len = file_len - absolute_offset; - let to_read_in_file: usize = std::cmp::min(file_remaining_len, buf.len() as u64) - .try_into() - .context("read size exceeds usize::MAX")?; - - trace!( - "piece={}, handle={}, file_idx={}, seeking to {}. To read chunk: {:?}", - chunk_info.piece_index, who_sent, file_idx, absolute_offset, &chunk_info - ); - if file_info.attrs.padding { - buf[..to_read_in_file].fill(0); - } else { - self.files - .pread_exact(file_idx, absolute_offset, &mut buf[..to_read_in_file]) - .with_context(|| { - format!("error reading {file_idx} bytes, file_id: {to_read_in_file}") - })?; - } - - buf = &mut buf[to_read_in_file..]; - - if buf.is_empty() { - break; - } - - absolute_offset = 0; - } - - Ok(()) - } - - pub fn write_chunk( - &self, - who_sent: PeerHandle, - data: &Piece>, - chunk_info: &ChunkInfo, - ) -> anyhow::Result<()> { - let mut absolute_offset = self.torrent.lengths().chunk_absolute_offset(chunk_info); - let mut data = DoubleBufHelper::new(data.data().0, data.data().1); - - for (file_idx, file_info) in self.file_infos.iter().enumerate() { - let file_len = file_info.len; - if absolute_offset > file_len { - absolute_offset -= file_len; - continue; - } - - let remaining_len = file_len - absolute_offset; - let to_write: usize = std::cmp::min(data.len() as u64, remaining_len) - .try_into() - .context("write size exceeds usize::MAX (likely a 32-bit platform limitation)")?; - - trace!( - "piece={}, chunk={:?}, handle={}, begin={}, file={}, writing {} bytes at {}", - chunk_info.piece_index, - chunk_info, - who_sent, - chunk_info.offset, - file_idx, - to_write, - absolute_offset - ); - let slices = data.as_ioslices(to_write); - debug_assert_eq!(slices[0].len() + slices[1].len(), to_write); - if !file_info.attrs.padding { - let written = self - .files - .pwrite_all_vectored(file_idx, absolute_offset, slices) - .with_context(|| { - format!( - "error writing to file {file_idx} (\"{:?}\")", - file_info.relative_filename - ) - })?; - debug_assert_eq!(written, to_write); - } - data.advance(to_write); - if data.is_empty() { - break; - } - - absolute_offset = 0; - } - - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/auth.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/auth.rs deleted file mode 100644 index 930297f9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/auth.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; - -const ACCESS_TOKEN_TTL: Duration = Duration::from_secs(15 * 60); // 15 minutes -const REFRESH_TOKEN_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); // 30 days - -struct TokenEntry { - expires_at: Instant, -} - -#[derive(Default)] -pub struct TokenStore { - access_tokens: RwLock>, - refresh_tokens: RwLock>, -} - -#[derive(Serialize)] -pub struct TokenResponse { - pub access_token: String, - pub refresh_token: String, - pub token_type: &'static str, - pub expires_in: u64, -} - -#[derive(Deserialize)] -pub struct LoginRequest { - pub username: String, - pub password: String, -} - -#[derive(Deserialize)] -pub struct RefreshRequest { - pub refresh_token: String, -} - -#[derive(Deserialize)] -pub struct LogoutRequest { - pub refresh_token: String, -} - -fn generate_token() -> String { - let bytes: [u8; 32] = rand::random(); - hex::encode(bytes) -} - -impl TokenStore { - pub fn new() -> Self { - Self { - access_tokens: RwLock::new(HashMap::new()), - refresh_tokens: RwLock::new(HashMap::new()), - } - } - - pub fn create_tokens(&self) -> TokenResponse { - let access_token = generate_token(); - let refresh_token = generate_token(); - let now = Instant::now(); - - self.access_tokens.write().insert( - access_token.clone(), - TokenEntry { - expires_at: now + ACCESS_TOKEN_TTL, - }, - ); - self.refresh_tokens.write().insert( - refresh_token.clone(), - TokenEntry { - expires_at: now + REFRESH_TOKEN_TTL, - }, - ); - - TokenResponse { - access_token, - refresh_token, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL.as_secs(), - } - } - - pub fn validate_access_token(&self, token: &str) -> bool { - let tokens = self.access_tokens.read(); - tokens - .get(token) - .is_some_and(|entry| entry.expires_at > Instant::now()) - } - - pub fn refresh(&self, refresh_token: &str) -> Option { - let valid = { - let tokens = self.refresh_tokens.read(); - tokens - .get(refresh_token) - .is_some_and(|entry| entry.expires_at > Instant::now()) - }; - - if !valid { - return None; - } - - // Revoke the old refresh token (rotation) - self.refresh_tokens.write().remove(refresh_token); - - Some(self.create_tokens()) - } - - pub fn revoke_refresh_token(&self, refresh_token: &str) { - self.refresh_tokens.write().remove(refresh_token); - } - - pub fn cleanup_expired(&self) { - let now = Instant::now(); - self.access_tokens - .write() - .retain(|_, entry| entry.expires_at > now); - self.refresh_tokens - .write() - .retain(|_, entry| entry.expires_at > now); - } -} - -// --- Credential Store --- - -#[derive(Serialize, Deserialize, Clone)] -pub struct StoredCredentials { - pub username: String, - pub password: String, -} - -pub struct CredentialStore { - credentials: RwLock>, - file_path: PathBuf, -} - -impl CredentialStore { - pub fn new(config_dir: PathBuf) -> Self { - let file_path = config_dir.join("credentials.json"); - let credentials = if file_path.exists() { - match std::fs::read_to_string(&file_path) { - Ok(contents) => serde_json::from_str(&contents).ok(), - Err(_) => None, - } - } else { - None - }; - Self { - credentials: RwLock::new(credentials), - file_path, - } - } - - pub fn has_credentials(&self) -> bool { - self.credentials.read().is_some() - } - - pub fn get_credentials(&self) -> Option { - self.credentials.read().clone() - } - - pub fn set_credentials(&self, creds: StoredCredentials) -> Result<(), std::io::Error> { - let json = serde_json::to_string_pretty(&creds).map_err(std::io::Error::other)?; - // Create parent directory if needed - if let Some(parent) = self.file_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&self.file_path, &json)?; - // Set file permissions to owner-only on unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&self.file_path, std::fs::Permissions::from_mode(0o600))?; - } - *self.credentials.write() = Some(creds); - Ok(()) - } - - pub fn validate(&self, username: &str, password: &str) -> bool { - match &*self.credentials.read() { - Some(creds) => { - super::constant_time_eq(username.as_bytes(), creds.username.as_bytes()) - && super::constant_time_eq(password.as_bytes(), creds.password.as_bytes()) - } - None => false, - } - } -} - -// --- HTTP Handlers --- - -use axum::{Json, extract::State, response::IntoResponse}; -use http::StatusCode; - -use super::HttpApi; - -type ApiState = Arc; - -// --- Auth Status --- - -#[derive(Serialize)] -pub struct AuthStatus { - pub auth_enabled: bool, - pub setup_required: bool, -} - -pub async fn h_auth_status(State(state): State) -> impl IntoResponse { - let has_env_auth = state.opts.basic_auth.is_some(); - let has_stored_creds = state - .opts - .credential_store - .as_ref() - .is_some_and(|cs| cs.has_credentials()); - let has_creds = has_env_auth || has_stored_creds; - Json(AuthStatus { - auth_enabled: has_creds, - setup_required: !has_creds, - }) -} - -// --- Auth Setup (first-boot) --- - -#[derive(Deserialize)] -pub struct SetupRequest { - pub username: String, - pub password: String, -} - -pub async fn h_auth_setup( - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - let cs = match &state.opts.credential_store { - Some(cs) => cs, - None => { - return (StatusCode::NOT_FOUND, "credential management not available").into_response(); - } - }; - - // Only allow if no credentials exist anywhere - if cs.has_credentials() || state.opts.basic_auth.is_some() { - return (StatusCode::FORBIDDEN, "credentials already configured").into_response(); - } - - if req.username.is_empty() || req.password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - "username and password are required", - ) - .into_response(); - } - - match cs.set_credentials(StoredCredentials { - username: req.username, - password: req.password, - }) { - Ok(_) => { - // Create tokens for the new user so they're immediately logged in - if let Some(ts) = &state.opts.token_store { - let tokens = ts.create_tokens(); - return (StatusCode::OK, Json(tokens)).into_response(); - } - StatusCode::OK.into_response() - } - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to save credentials: {e}"), - ) - .into_response(), - } -} - -// --- Change Credentials --- - -#[derive(Deserialize)] -pub struct ChangeCredentialsRequest { - pub current_password: String, - pub new_username: Option, - pub new_password: Option, -} - -pub async fn h_auth_change_credentials( - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - let cs = match &state.opts.credential_store { - Some(cs) => cs, - None => { - return (StatusCode::NOT_FOUND, "credential management not available").into_response(); - } - }; - - // Get current credentials from credential store or env var fallback - let current_creds = match cs.get_credentials() { - Some(c) => c, - None => match &state.opts.basic_auth { - Some((u, p)) => StoredCredentials { - username: u.clone(), - password: p.clone(), - }, - None => { - return (StatusCode::NOT_FOUND, "no credentials configured").into_response(); - } - }, - }; - - // Verify current password - if !super::constant_time_eq( - req.current_password.as_bytes(), - current_creds.password.as_bytes(), - ) { - return (StatusCode::UNAUTHORIZED, "current password is incorrect").into_response(); - } - - let new_creds = StoredCredentials { - username: req.new_username.unwrap_or(current_creds.username), - password: req.new_password.unwrap_or(current_creds.password), - }; - - match cs.set_credentials(new_creds) { - Ok(_) => StatusCode::OK.into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("failed to save credentials: {e}"), - ) - .into_response(), - } -} - -// --- Login --- - -pub async fn h_auth_login( - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - // Check credential store first, then fall back to basic_auth - let valid = if let Some(cs) = &state.opts.credential_store { - if cs.has_credentials() { - cs.validate(&req.username, &req.password) - } else { - // Fall back to env var credentials - match &state.opts.basic_auth { - Some((u, p)) => { - super::constant_time_eq(req.username.as_bytes(), u.as_bytes()) - && super::constant_time_eq(req.password.as_bytes(), p.as_bytes()) - } - None => false, - } - } - } else { - match &state.opts.basic_auth { - Some((u, p)) => { - super::constant_time_eq(req.username.as_bytes(), u.as_bytes()) - && super::constant_time_eq(req.password.as_bytes(), p.as_bytes()) - } - None => { - return (StatusCode::NOT_FOUND, "authentication not configured").into_response(); - } - } - }; - - if !valid { - return (StatusCode::UNAUTHORIZED, "invalid credentials").into_response(); - } - - let token_store = match &state.opts.token_store { - Some(ts) => ts, - None => { - return (StatusCode::NOT_FOUND, "token auth not configured").into_response(); - } - }; - - token_store.cleanup_expired(); - let tokens = token_store.create_tokens(); - (StatusCode::OK, Json(tokens)).into_response() -} - -pub async fn h_auth_refresh( - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - let token_store = match &state.opts.token_store { - Some(ts) => ts, - None => { - return (StatusCode::NOT_FOUND, "token auth not configured").into_response(); - } - }; - - match token_store.refresh(&req.refresh_token) { - Some(tokens) => (StatusCode::OK, Json(tokens)).into_response(), - None => (StatusCode::UNAUTHORIZED, "invalid or expired refresh token").into_response(), - } -} - -pub async fn h_auth_logout( - State(state): State, - Json(req): Json, -) -> impl IntoResponse { - if let Some(ts) = &state.opts.token_store { - ts.revoke_refresh_token(&req.refresh_token); - } - StatusCode::NO_CONTENT -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/configure.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/configure.rs deleted file mode 100644 index a278a23a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/configure.rs +++ /dev/null @@ -1,150 +0,0 @@ -use axum::{Json, extract::Query, extract::State, response::IntoResponse}; -use serde::{Deserialize, Serialize}; - -use super::ApiState; -use crate::{ - api::{EmptyJsonResponse, Result}, - limits::LimitsConfig, -}; - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/limits", - request_body(content = LimitsConfig, description = "Rate limits configuration"), - responses( - (status = 200, description = "Rate limits updated", body = EmptyJsonResponse) - ) -))] -pub async fn h_update_session_ratelimits( - State(state): State, - Json(limits): Json, -) -> Result { - state - .api - .session() - .ratelimits - .set_upload_bps(limits.upload_bps); - state - .api - .session() - .ratelimits - .set_download_bps(limits.download_bps); - if let Some(peer_limit) = limits.peer_limit { - state.api.session().set_peer_limit(peer_limit); - } - if let Some(init_limit) = limits.concurrent_init_limit { - state.api.session().set_concurrent_init_limit(init_limit); - } - Ok(Json(EmptyJsonResponse {})) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/limits", - responses( - (status = 200, description = "Current rate limits", body = LimitsConfig) - ) -))] -pub async fn h_get_session_ratelimits(State(state): State) -> Result { - let mut config = state.api.session().ratelimits.get_config(); - config.peer_limit = Some(state.api.session().get_peer_limit()); - config.concurrent_init_limit = Some(state.api.session().get_concurrent_init_limit()); - Ok(Json(config)) -} - -// --- Folder management --- - -#[derive(Serialize, Deserialize)] -pub struct FoldersConfig { - pub download_folder: String, - pub completed_folder: Option, -} - -pub async fn h_get_folders(State(state): State) -> Result { - Ok(Json(FoldersConfig { - download_folder: state.api.api_output_folder(), - completed_folder: state.api.api_completed_folder(), - })) -} - -pub async fn h_set_folders( - State(state): State, - Json(config): Json, -) -> Result { - state.api.api_set_output_folder(config.download_folder); - state.api.api_set_completed_folder(config.completed_folder); - Ok(Json(EmptyJsonResponse {})) -} - -// --- Directory browsing --- - -#[derive(Deserialize)] -pub struct BrowseQuery { - pub path: Option, -} - -#[derive(Serialize)] -pub struct DirEntry { - pub name: String, - pub path: String, - pub is_dir: bool, -} - -#[derive(Serialize)] -pub struct BrowseResponse { - pub current: String, - pub parent: Option, - pub entries: Vec, -} - -pub async fn h_browse_directory(Query(query): Query) -> Result { - let path = query - .path - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - // Default to root on Unix - std::path::PathBuf::from("/") - }); - - // Canonicalize to prevent directory traversal - let canonical = path - .canonicalize() - .map_err(|e| anyhow::anyhow!("invalid path: {e}"))?; - - let parent = canonical.parent().map(|p| p.to_string_lossy().into_owned()); - - let mut entries = Vec::new(); - let read_dir = std::fs::read_dir(&canonical) - .map_err(|e| anyhow::anyhow!("cannot read directory: {e}"))?; - - for entry in read_dir.flatten() { - let file_type = match entry.file_type() { - Ok(ft) => ft, - Err(_) => continue, - }; - let name = entry.file_name().to_string_lossy().into_owned(); - // Skip hidden files/dirs - if name.starts_with('.') { - continue; - } - let entry_path = entry.path(); - entries.push(DirEntry { - name, - path: entry_path.to_string_lossy().into_owned(), - is_dir: file_type.is_dir(), - }); - } - - // Sort: directories first, then alphabetically - entries.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - - Ok(Json(BrowseResponse { - current: canonical.to_string_lossy().into_owned(), - parent, - entries, - })) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/dht.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/dht.rs deleted file mode 100644 index c0c33d20..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/dht.rs +++ /dev/null @@ -1,26 +0,0 @@ -use axum::{extract::State, response::IntoResponse}; - -use super::ApiState; -use crate::api::Result; - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/dht/stats", - responses( - (status = 200, description = "DHT statistics") - ) -))] -pub async fn h_dht_stats(State(state): State) -> Result { - state.api.api_dht_stats().map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/dht/table", - responses( - (status = 200, description = "DHT routing table") - ) -))] -pub async fn h_dht_table(State(state): State) -> Result { - state.api.api_dht_table().map(axum::Json) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/indexarr.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/indexarr.rs deleted file mode 100644 index c924a929..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/indexarr.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! Indexarr integration — proxy handlers that forward requests to an Indexarr instance. -//! -//! All endpoints return 404 when Indexarr integration is not enabled -//! (i.e. `state.opts.indexarr_url` is `None`). - -use std::collections::HashMap; - -use axum::{ - Json, - extract::{Path, Query, State}, - response::{IntoResponse, Response}, -}; -use http::StatusCode; -use serde_json::json; - -use super::ApiState; - -/// Extract Indexarr config from state, returning owned values. -/// Returns Err(Response) if Indexarr is not enabled. -fn indexarr_config(state: &ApiState) -> Result<(String, Option), Box> { - match &state.opts.indexarr_url { - Some(url) => Ok(( - url.trim_end_matches('/').to_string(), - state.opts.indexarr_api_key.clone(), - )), - None => Err(Box::new( - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "Indexarr integration is not enabled"})), - ) - .into_response(), - )), - } -} - -/// Forward a GET request to Indexarr, injecting the API key. -async fn proxy_get( - base_url: String, - api_key: Option, - path: &str, - params: &HashMap, -) -> Response { - let client = reqwest::Client::new(); - let url = format!("{}{}", base_url, path); - let mut req = client.get(&url).query(params); - if let Some(key) = &api_key { - req = req.header("X-Api-Key", key); - } - - match req.send().await { - Ok(resp) => { - let status = - StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); - let body = resp.text().await.unwrap_or_default(); - (status, [("Content-Type", "application/json")], body).into_response() - } - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("Failed to reach Indexarr: {e}")})), - ) - .into_response(), - } -} - -/// Forward a POST request (JSON body) to Indexarr, injecting the API key. -async fn proxy_post_json( - base_url: String, - api_key: Option, - path: &str, - body: serde_json::Value, -) -> Response { - let client = reqwest::Client::new(); - let url = format!("{}{}", base_url, path); - let mut req = client.post(&url).json(&body); - if let Some(key) = &api_key { - req = req.header("X-Api-Key", key); - } - - match req.send().await { - Ok(resp) => { - let status = - StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); - let body = resp.text().await.unwrap_or_default(); - (status, [("Content-Type", "application/json")], body).into_response() - } - Err(e) => ( - StatusCode::BAD_GATEWAY, - Json(json!({"error": format!("Failed to reach Indexarr: {e}")})), - ) - .into_response(), - } -} - -// --------------------------------------------------------------------------- -// Public handler functions -// --------------------------------------------------------------------------- - -/// GET /indexarr/status — check if Indexarr is enabled and reachable. -pub async fn h_indexarr_status(State(state): State) -> Response { - let Some(url) = &state.opts.indexarr_url else { - return Json(json!({ - "enabled": false, - })) - .into_response(); - }; - - let client = reqwest::Client::new(); - let base = url.trim_end_matches('/'); - let mut req = client.get(format!("{base}/health")); - if let Some(key) = &state.opts.indexarr_api_key { - req = req.header("X-Api-Key", key); - } - - match req.send().await { - Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp.json().await.unwrap_or(json!({})); - Json(json!({ - "enabled": true, - "reachable": true, - "indexarr": body, - })) - .into_response() - } - Ok(resp) => Json(json!({ - "enabled": true, - "reachable": false, - "error": format!("Indexarr returned status {}", resp.status()), - })) - .into_response(), - Err(e) => Json(json!({ - "enabled": true, - "reachable": false, - "error": format!("{e}"), - })) - .into_response(), - } -} - -/// GET /indexarr/search — proxy to Indexarr search API. -pub async fn h_indexarr_search( - State(state): State, - Query(params): Query>, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_get(base_url, api_key, "/api/v1/search", ¶ms).await -} - -/// GET /indexarr/recent — proxy to Indexarr recent torrents. -pub async fn h_indexarr_recent( - State(state): State, - Query(params): Query>, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_get(base_url, api_key, "/api/v1/recent", ¶ms).await -} - -/// GET /indexarr/trending — proxy to Indexarr trending torrents. -pub async fn h_indexarr_trending( - State(state): State, - Query(params): Query>, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_get(base_url, api_key, "/api/v1/trending", ¶ms).await -} - -/// GET /indexarr/torrent/{info_hash} — proxy to Indexarr torrent detail. -pub async fn h_indexarr_torrent_detail( - State(state): State, - Path(info_hash): Path, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_get( - base_url, - api_key, - &format!("/api/v1/torrent/{info_hash}"), - &HashMap::new(), - ) - .await -} - -/// GET /indexarr/identity/status — proxy to Indexarr identity status. -pub async fn h_indexarr_identity_status(State(state): State) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_get( - base_url, - api_key, - "/api/v1/identity/status", - &HashMap::new(), - ) - .await -} - -/// POST /indexarr/identity/acknowledge — proxy to Indexarr identity acknowledge. -pub async fn h_indexarr_identity_acknowledge(State(state): State) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_post_json(base_url, api_key, "/api/v1/identity/acknowledge", json!({})).await -} - -/// GET /indexarr/sync/preferences — proxy to Indexarr sync preferences. -pub async fn h_indexarr_sync_preferences_get(State(state): State) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_get( - base_url, - api_key, - "/api/v1/system/sync/preferences", - &HashMap::new(), - ) - .await -} - -/// POST /indexarr/sync/preferences — proxy to Indexarr sync preferences. -pub async fn h_indexarr_sync_preferences_set( - State(state): State, - Json(body): Json, -) -> Response { - let (base_url, api_key) = match indexarr_config(&state) { - Ok(v) => v, - Err(r) => return *r, - }; - proxy_post_json(base_url, api_key, "/api/v1/system/sync/preferences", body).await -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/logging.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/logging.rs deleted file mode 100644 index c8acea25..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/logging.rs +++ /dev/null @@ -1,39 +0,0 @@ -use axum::{extract::State, response::IntoResponse}; -#[cfg(feature = "tracing-subscriber-utils")] -use futures::TryStreamExt; -#[cfg(feature = "tracing-subscriber-utils")] -use tracing::debug; - -use super::ApiState; -use crate::api::Result; - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/rust_log", - request_body(content = String, description = "New RUST_LOG value"), - responses( - (status = 200, description = "RUST_LOG updated") - ) -))] -pub async fn h_set_rust_log( - State(state): State, - new_value: String, -) -> Result { - state.api.api_set_rust_log(new_value).map(axum::Json) -} - -#[cfg(feature = "tracing-subscriber-utils")] -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/stream_logs", - responses( - (status = 200, description = "Continuous log stream (Server-Sent Events)") - ) -))] -pub async fn h_stream_logs(State(state): State) -> Result { - let s = state.api.api_log_lines_stream()?.map_err(|e| { - debug!(error=%e, "stream_logs"); - e - }); - Ok(axum::body::Body::from_stream(s)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/mod.rs deleted file mode 100644 index 10e83f29..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/mod.rs +++ /dev/null @@ -1,227 +0,0 @@ -pub(crate) mod configure; -pub(crate) mod dht; -pub(crate) mod indexarr; -pub(crate) mod logging; -pub(crate) mod other; -pub(crate) mod playlist; -pub(crate) mod qbit_compat; -pub(crate) mod rss; -pub(crate) mod streaming; -pub(crate) mod torrents; - -use std::sync::Arc; - -#[cfg(feature = "webui")] -use axum::response::Redirect; - -use axum::{ - Router, - response::IntoResponse, - routing::{delete, get, post, put}, -}; -use http::request::Parts; - -use super::HttpApi; -type ApiState = Arc; - -#[allow(unused_variables)] -async fn h_api_root(parts: Parts) -> impl IntoResponse { - // If browser, and webui enabled, redirect to web - #[cfg(feature = "webui")] - { - if parts - .headers - .get("Accept") - .and_then(|h| h.to_str().ok()) - .is_some_and(|h| h.contains("text/html")) - { - return Redirect::temporary("./web/").into_response(); - } - } - - let json = serde_json::json!({ - "apis": { - "GET /": "list all available APIs", - "GET /dht/stats": "DHT stats", - "GET /dht/table": "DHT routing table", - "GET /torrents": "List torrents", - "GET /torrents/playlist": "Generate M3U8 playlist for all files in all torrents", - "GET /stats": "Global session stats", - "GET /metrics": "Prometheus metrics", - "GET /stream_logs": "Continuously stream logs", - "GET /web/": "Web UI", - "GET /torrents/playlist": "Playlist for supported players", - "GET /torrents/{id_or_infohash}": "Torrent details", - "GET /torrents/{id_or_infohash}/metadata": "Download the corresponding torrent file", - "GET /torrents/{id_or_infohash}/haves": "The bitfield of have pieces", - "GET /torrents/{id_or_infohash}/playlist": "Generate M3U8 playlist for this torrent", - "GET /torrents/{id_or_infohash}/stats/v1": "Torrent stats", - "GET /torrents/{id_or_infohash}/peer_stats": "Per peer stats", - "GET /torrents/{id_or_infohash}/peer_stats/prometheus": "Per peer stats in prometheus format", - "GET /torrents/{id_or_infohash}/stream/{file_idx}": "Stream a file. Accepts Range header to seek.", - "GET /torrents/{id_or_infohash}/playlist": "Playlist for supported players", - "POST /torrents": "Add a torrent here. magnet: or http:// or a local file.", - "POST /torrents/create": "Create a torrent and start seeding. Body should be a local folder", - "POST /torrents/resolve_magnet": "Resolve a magnet to torrent file bytes", - "POST /torrents/{id_or_infohash}/pause": "Pause torrent", - "POST /torrents/{id_or_infohash}/start": "Resume torrent", - "POST /torrents/{id_or_infohash}/forget": "Forget about the torrent, keep the files", - "POST /torrents/{id_or_infohash}/delete": "Forget about the torrent, remove the files", - "POST /torrents/{id_or_infohash}/add_peers": "Add peers (newline-delimited)", - "POST /torrents/{id_or_infohash}/update_only_files": "Change the selection of files to download. You need to POST json of the following form {\"only_files\": [0, 1, 2]}", - "POST /rust_log": "Set RUST_LOG to this post launch (for debugging)", - }, - "server": "rtbit", - "version": env!("CARGO_PKG_VERSION"), - }); - - ([("Content-Type", "application/json")], axum::Json(json)).into_response() -} - -pub fn make_api_router(state: ApiState) -> Router { - let mut api_router = Router::new() - .route("/", get(h_api_root)) - .route("/rust_log", post(logging::h_set_rust_log)) - .route("/dht/stats", get(dht::h_dht_stats)) - .route("/dht/table", get(dht::h_dht_table)) - .route("/stats", get(torrents::h_session_stats)) - .route("/torrents", get(torrents::h_torrents_list)) - .route("/torrents/{id}", get(torrents::h_torrent_details)) - .route("/torrents/{id}/haves", get(torrents::h_torrent_haves)) - .route("/torrents/{id}/metadata", get(torrents::h_metadata)) - .route("/torrents/{id}/stats", get(torrents::h_torrent_stats_v0)) - .route("/torrents/{id}/stats/v1", get(torrents::h_torrent_stats_v1)) - .route("/torrents/{id}/peer_stats", get(torrents::h_peer_stats)) - .route( - "/torrents/{id}/peer_stats/prometheus", - get(torrents::h_peer_stats_prometheus), - ) - .route("/torrents/{id}/playlist", get(playlist::h_torrent_playlist)) - .route("/torrents/playlist", get(playlist::h_global_playlist)) - .route("/torrents/resolve_magnet", post(other::h_resolve_magnet)) - .route( - "/torrents/{id}/stream/{file_id}", - get(streaming::h_torrent_stream_file), - ) - .route( - "/torrents/{id}/stream/{file_id}/{*filename}", - get(streaming::h_torrent_stream_file), - ) - .route("/torrents/limits", get(configure::h_get_session_ratelimits)) - .route("/torrents/categories", get(torrents::h_list_categories)) - .route("/session/folders", get(configure::h_get_folders)) - .route("/fs/browse", get(configure::h_browse_directory)); - - #[cfg(feature = "tracing-subscriber-utils")] - { - api_router = api_router - .route("/stream_logs", get(logging::h_stream_logs)); - } - - if !state.opts.read_only { - api_router = api_router - .route("/torrents", post(torrents::h_torrents_post)) - .route( - "/torrents/limits", - post(configure::h_update_session_ratelimits), - ) - .route( - "/torrents/{id}/pause", - post(torrents::h_torrent_action_pause), - ) - .route( - "/torrents/{id}/start", - post(torrents::h_torrent_action_start), - ) - .route( - "/torrents/{id}/forget", - post(torrents::h_torrent_action_forget), - ) - .route( - "/torrents/{id}/delete", - post(torrents::h_torrent_action_delete), - ) - .route( - "/torrents/{id}/update_only_files", - post(torrents::h_torrent_action_update_only_files), - ) - .route("/torrents/{id}/add_peers", post(torrents::h_add_peers)) - .route("/torrents/create", post(torrents::h_create_torrent)) - .route( - "/torrents/categories", - post(torrents::h_create_or_edit_category), - ) - .route( - "/torrents/categories/{name}", - delete(torrents::h_delete_category), - ) - .route( - "/torrents/{id}/set_category", - post(torrents::h_set_torrent_category), - ) - .route("/session/folders", post(configure::h_set_folders)); - } - - // Indexarr integration proxy endpoints - api_router = api_router - .route("/indexarr/status", get(indexarr::h_indexarr_status)) - .route("/indexarr/search", get(indexarr::h_indexarr_search)) - .route("/indexarr/recent", get(indexarr::h_indexarr_recent)) - .route("/indexarr/trending", get(indexarr::h_indexarr_trending)) - .route( - "/indexarr/torrent/{info_hash}", - get(indexarr::h_indexarr_torrent_detail), - ) - .route( - "/indexarr/identity/status", - get(indexarr::h_indexarr_identity_status), - ) - .route( - "/indexarr/identity/acknowledge", - post(indexarr::h_indexarr_identity_acknowledge), - ) - .route( - "/indexarr/sync/preferences", - get(indexarr::h_indexarr_sync_preferences_get), - ) - .route( - "/indexarr/sync/preferences", - post(indexarr::h_indexarr_sync_preferences_set), - ); - - // RSS endpoints - api_router = api_router - .route("/rss/feeds", get(rss::h_rss_feeds_list)) - .route("/rss/feeds", post(rss::h_rss_feed_add)) - .route("/rss/feeds/{name}", put(rss::h_rss_feed_update)) - .route("/rss/feeds/{name}", delete(rss::h_rss_feed_delete)) - .route("/rss/items", get(rss::h_rss_items_list)) - .route("/rss/items/{id}/download", post(rss::h_rss_item_download)) - .route("/rss/rules", get(rss::h_rss_rules_list)) - .route("/rss/rules", post(rss::h_rss_rule_add)) - .route("/rss/rules/{id}", put(rss::h_rss_rule_update)) - .route("/rss/rules/{id}", delete(rss::h_rss_rule_delete)) - .route("/rss/settings", get(rss::h_rss_settings_get)); - - // Auth endpoints — status is always available; login/refresh/logout need token store; - // setup and change_credentials need credential store. - api_router = api_router.route("/auth/status", get(super::auth::h_auth_status)); - - if state.opts.token_store.is_some() { - api_router = api_router - .route("/auth/login", post(super::auth::h_auth_login)) - .route("/auth/refresh", post(super::auth::h_auth_refresh)) - .route("/auth/logout", post(super::auth::h_auth_logout)); - } - - if state.opts.credential_store.is_some() { - api_router = api_router - .route("/auth/setup", post(super::auth::h_auth_setup)) - .route( - "/auth/change_credentials", - post(super::auth::h_auth_change_credentials), - ); - } - - api_router.with_state(state) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/other.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/other.rs deleted file mode 100644 index 555f426e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/other.rs +++ /dev/null @@ -1,86 +0,0 @@ -use anyhow::Context; -use axum::{extract::State, response::IntoResponse}; -use bencode::AsDisplay; -use buffers::ByteBuf; -use http::{HeaderMap, HeaderValue, StatusCode}; - -use super::ApiState; -use crate::{ - AddTorrent, AddTorrentOptions, ListOnlyResponse, api::Result, http_api::timeout::Timeout, -}; - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/resolve_magnet", - request_body(content = String, description = "Magnet link or URL to resolve"), - responses( - (status = 200, description = "Resolved torrent file bytes", content_type = "application/x-bittorrent") - ) -))] -pub async fn h_resolve_magnet( - State(state): State, - Timeout(timeout): Timeout<600_000, 3_600_000>, - inp_headers: HeaderMap, - url: String, -) -> Result { - let added = tokio::time::timeout( - timeout, - state.api.session().add_torrent( - AddTorrent::from_url(&url), - Some(AddTorrentOptions { - list_only: true, - ..Default::default() - }), - ), - ) - .await - .context("timeout")??; - - let (info, content) = match added { - crate::AddTorrentResponse::AlreadyManaged(_, handle) => { - handle.with_metadata(|r| (r.info.clone(), r.torrent_bytes.clone()))? - } - crate::AddTorrentResponse::ListOnly(ListOnlyResponse { - info, - torrent_bytes, - .. - }) => (info, torrent_bytes), - crate::AddTorrentResponse::Added(_, _) => { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - "bug: torrent was added to session, but shouldn't have been", - ) - .into()); - } - }; - - let mut headers = HeaderMap::new(); - - if inp_headers - .get("Accept") - .and_then(|v| std::str::from_utf8(v.as_bytes()).ok()) - == Some("application/json") - { - let data = bencode::dyn_from_bytes::>(&content) - .map_err(|e| { - tracing::trace!("error decoding .torrent file content: {e:#}"); - e.into_kind() - }) - .context("error decoding .torrent file content")?; - let data = serde_json::to_string(&data).context("error serializing")?; - headers.insert("Content-Type", HeaderValue::from_static("application/json")); - return Ok((headers, data).into_response()); - } - - headers.insert( - "Content-Type", - HeaderValue::from_static("application/x-bittorrent"), - ); - - if let Some(name) = info.name() - && let Ok(h) = HeaderValue::from_str(&format!("attachment; filename=\"{name}.torrent\"")) - { - headers.insert("Content-Disposition", h); - } - Ok((headers, content).into_response()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/playlist.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/playlist.rs deleted file mode 100644 index 0bafb76b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/playlist.rs +++ /dev/null @@ -1,127 +0,0 @@ -use anyhow::Context; -use axum::{ - extract::{Path, State}, - response::IntoResponse, -}; -use http::{HeaderMap, StatusCode}; -use itertools::Itertools; - -use super::ApiState; -use crate::{ - ManagedTorrent, - api::{Result, TorrentIdOrHash}, - api_error::WithStatus, -}; - -fn torrent_playlist_items(handle: &ManagedTorrent) -> Result> { - let mut playlist_items = handle - .metadata - .load() - .as_ref() - .context("torrent metadata not resolved")? - .info - .iter_file_details() - .enumerate() - .filter_map(|(file_idx, file_details)| { - let filename = file_details.filename.to_vec().join("/"); - let is_playable = mime_guess::from_path(&filename) - .first() - .map(|mime| { - mime.type_() == mime_guess::mime::VIDEO - || mime.type_() == mime_guess::mime::AUDIO - }) - .unwrap_or(false); - if is_playable { - let filename = urlencoding::encode(&filename); - Some((file_idx, filename.into_owned())) - } else { - None - } - }) - .collect::>(); - playlist_items.sort_by(|left, right| left.1.cmp(&right.1)); - Ok(playlist_items) -} - -fn get_host(headers: &HeaderMap) -> Result<&str> { - headers - .get("host") - .ok_or("Missing host header") - .and_then(|h| h.to_str().map_err(|_| "hostname is not a string")) - .with_status(StatusCode::BAD_REQUEST) -} - -fn build_playlist_content>( - host: &str, - it: I, -) -> impl IntoResponse + use { - let body = it - .into_iter() - .map(|(torrent_idx, file_idx, filename)| { - // TODO: add #EXTINF:{duration} and maybe codecs ? - format!("http://{host}/torrents/{torrent_idx}/stream/{file_idx}/{filename}") - }) - .join("\r\n"); - ( - [ - ("Content-Type", "application/mpegurl; charset=utf-8"), - ( - "Content-Disposition", - "attachment; filename=\"rtbit-playlist.m3u8\"", - ), - ], - format!("#EXTM3U\r\n{body}"), // https://en.wikipedia.org/wiki/M3U - ) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/playlist", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "M3U8 playlist for this torrent", content_type = "application/mpegurl") - ) -))] -pub async fn h_torrent_playlist( - State(state): State, - headers: HeaderMap, - Path(idx): Path, -) -> Result { - let host = get_host(&headers)?; - let playlist_items = torrent_playlist_items(&*state.api.mgr_handle(idx)?)?; - Ok(build_playlist_content( - host, - playlist_items - .into_iter() - .map(move |(file_idx, filename)| (idx, file_idx, filename)), - )) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/playlist", - responses( - (status = 200, description = "M3U8 playlist for all torrents", content_type = "application/mpegurl") - ) -))] -pub async fn h_global_playlist( - State(state): State, - headers: HeaderMap, -) -> Result { - let host = get_host(&headers)?; - let all_items = state.api.session().with_torrents(|torrents| { - torrents - .filter_map(|(torrent_idx, handle)| { - torrent_playlist_items(handle) - .map(move |items| { - items.into_iter().map(move |(file_idx, filename)| { - (torrent_idx.into(), file_idx, filename) - }) - }) - .ok() - }) - .flatten() - .collect::>() - }); - Ok(build_playlist_content(host, all_items)) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/qbit_compat.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/qbit_compat.rs deleted file mode 100644 index f85d4260..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/qbit_compat.rs +++ /dev/null @@ -1,1173 +0,0 @@ -//! qBittorrent WebUI API v2 compatibility layer. -//! -//! This module implements a subset of the qBittorrent WebUI API v2 so that -//! *arr apps (Sonarr, Radarr, etc.) can use rtbit as a download client by -//! pretending to be qBittorrent. - -use std::{ - collections::HashMap, - sync::Arc, - time::{Instant, SystemTime, UNIX_EPOCH}, -}; - -use axum::{ - Router, - extract::{Multipart, Query, State}, - response::IntoResponse, - routing::{get, post}, -}; -use bytes::Bytes; -use http::{HeaderMap, StatusCode, header::SET_COOKIE}; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use tracing::warn; - -use crate::{ - AddTorrent, AddTorrentOptions, - api::{Api, TorrentIdOrHash}, - torrent_state::stats::TorrentStatsState, -}; - -use super::ApiState; - -// --------------------------------------------------------------------------- -// Shared state for qBit compat layer -// --------------------------------------------------------------------------- - -/// In-memory session store for qBittorrent auth cookies. -struct QbitSessions { - sessions: RwLock>, -} - -impl QbitSessions { - fn new() -> Self { - Self { - sessions: RwLock::new(HashMap::new()), - } - } - - fn create_session(&self) -> String { - let sid: String = (0..32) - .map(|_| format!("{:02x}", rand::random::())) - .collect(); - self.sessions.write().insert(sid.clone(), Instant::now()); - sid - } - - fn validate_session(&self, sid: &str) -> bool { - let sessions = self.sessions.read(); - if let Some(created) = sessions.get(sid) { - // Sessions expire after 1 hour - created.elapsed().as_secs() < 3600 - } else { - false - } - } - - fn remove_session(&self, sid: &str) { - self.sessions.write().remove(sid); - } -} - -/// In-memory category store (categories are simple name->savePath mappings). -struct QbitCategories { - categories: RwLock>, -} - -impl QbitCategories { - fn new() -> Self { - Self { - categories: RwLock::new(HashMap::new()), - } - } -} - -#[derive(Clone, Serialize)] -struct QbitCategory { - name: String, - #[serde(rename = "savePath")] - save_path: String, -} - -/// Combined qBit compat state. -pub(crate) struct QbitState { - api_state: ApiState, - sessions: QbitSessions, - categories: QbitCategories, -} - -// --------------------------------------------------------------------------- -// Serializable response types (avoids json! macro recursion limit issues) -// --------------------------------------------------------------------------- - -#[derive(Serialize)] -struct QbitTorrentInfo { - added_on: u64, - amount_left: u64, - auto_tmm: bool, - availability: i32, - category: String, - completed: u64, - completion_on: i64, - content_path: String, - dl_limit: i32, - dlspeed: u64, - download_path: String, - downloaded: u64, - downloaded_session: u64, - eta: i64, - f_l_piece_prio: bool, - force_start: bool, - hash: String, - infohash_v1: String, - infohash_v2: String, - last_activity: u64, - magnet_uri: String, - max_ratio: i32, - max_seeding_time: i32, - name: String, - num_complete: u32, - num_incomplete: u32, - num_leechs: u32, - num_seeds: u32, - priority: u32, - progress: f64, - ratio: f64, - ratio_limit: i32, - save_path: String, - seeding_time: u64, - seeding_time_limit: i32, - seen_complete: i64, - seq_dl: bool, - size: u64, - state: String, - super_seeding: bool, - tags: String, - time_active: u64, - total_size: u64, - tracker: String, - trackers_count: usize, - up_limit: i32, - uploaded: u64, - uploaded_session: u64, - upspeed: u64, -} - -#[derive(Serialize)] -struct QbitTorrentProperties { - save_path: String, - creation_date: u64, - piece_size: u64, - comment: String, - total_wasted: u64, - total_uploaded: u64, - total_uploaded_session: u64, - total_downloaded: u64, - total_downloaded_session: u64, - up_limit: i32, - dl_limit: i32, - time_elapsed: u64, - seeding_time: u64, - nb_connections: u32, - nb_connections_limit: i32, - share_ratio: f64, - addition_date: u64, - completion_date: i64, - created_by: String, - dl_speed_avg: u64, - dl_speed: u64, - eta: i64, - last_seen: u64, - peers: u32, - peers_total: u32, - pieces_have: u32, - pieces_num: u32, - reannounce: u32, - seeds: u32, - seeds_total: u32, - total_size: u64, - up_speed_avg: u64, - up_speed: u64, -} - -#[derive(Serialize)] -struct QbitFileInfo { - index: usize, - name: String, - size: u64, - progress: f64, - priority: u8, - is_seed: bool, - piece_range: [u32; 2], - availability: f64, -} - -#[derive(Serialize)] -struct QbitBuildInfo { - qt: &'static str, - libtorrent: &'static str, - boost: &'static str, - openssl: &'static str, - bitness: u32, -} - -#[derive(Serialize)] -struct QbitTransferInfo { - dl_info_speed: u64, - dl_info_data: u64, - up_info_speed: u64, - up_info_data: u64, - dl_rate_limit: u64, - up_rate_limit: u64, - dht_nodes: u64, - connection_status: &'static str, -} - -#[derive(Serialize)] -struct QbitPreferences { - save_path: String, - temp_path_enabled: bool, - temp_path: String, - max_connec: i32, - max_connec_per_torrent: i32, - max_uploads: i32, - max_uploads_per_torrent: i32, - dl_limit: i32, - up_limit: i32, - dht: bool, - pex: bool, - lsd: bool, - encryption: u32, - queueing_enabled: bool, - locale: &'static str, - web_ui_port: u16, -} - -// --------------------------------------------------------------------------- -// Auth endpoints -// --------------------------------------------------------------------------- - -#[derive(Deserialize, Default)] -struct LoginForm { - #[serde(default)] - username: String, - #[serde(default)] - password: String, -} - -async fn h_auth_login(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: LoginForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - - let auth_ok = match &state.api_state.opts.basic_auth { - Some((expected_user, expected_pass)) => { - form.username == *expected_user && form.password == *expected_pass - } - None => true, - }; - - if auth_ok { - let sid = state.sessions.create_session(); - let cookie = format!("SID={sid}; Path=/; HttpOnly"); - (StatusCode::OK, [(SET_COOKIE, cookie)], "Ok.".to_string()).into_response() - } else { - (StatusCode::FORBIDDEN, "Fails.".to_string()).into_response() - } -} - -async fn h_auth_logout(State(state): State>, headers: HeaderMap) -> &'static str { - if let Some(sid) = extract_sid(&headers) { - state.sessions.remove_session(&sid); - } - "Ok." -} - -// --------------------------------------------------------------------------- -// App info endpoints -// --------------------------------------------------------------------------- - -async fn h_app_version() -> &'static str { - "v0.0.1" -} - -async fn h_app_webapi_version() -> &'static str { - "2.11.3" -} - -async fn h_app_build_info() -> impl IntoResponse { - axum::Json(QbitBuildInfo { - qt: "N/A", - libtorrent: "N/A", - boost: "N/A", - openssl: "N/A", - bitness: 64, - }) -} - -async fn h_app_preferences(State(state): State>) -> impl IntoResponse { - let save_path = state.api_state.api.session().with_torrents(|torrents| { - torrents - .next() - .map(|(_, t)| { - t.shared() - .options - .output_folder - .to_string_lossy() - .into_owned() - }) - .unwrap_or_default() - }); - - axum::Json(QbitPreferences { - save_path, - temp_path_enabled: false, - temp_path: String::new(), - max_connec: -1, - max_connec_per_torrent: -1, - max_uploads: -1, - max_uploads_per_torrent: -1, - dl_limit: 0, - up_limit: 0, - dht: true, - pex: true, - lsd: true, - encryption: 0, - queueing_enabled: false, - locale: "en", - web_ui_port: 3030, - }) -} - -// --------------------------------------------------------------------------- -// Transfer info -// --------------------------------------------------------------------------- - -async fn h_transfer_info(State(state): State>) -> impl IntoResponse { - let session_stats = state.api_state.api.api_session_stats(); - axum::Json(QbitTransferInfo { - dl_info_speed: session_stats.download_speed.as_bytes(), - dl_info_data: session_stats.counters.fetched_bytes, - up_info_speed: session_stats.upload_speed.as_bytes(), - up_info_data: session_stats.counters.uploaded_bytes, - dl_rate_limit: 0, - up_rate_limit: 0, - dht_nodes: 0, - connection_status: "connected", - }) -} - -// --------------------------------------------------------------------------- -// Torrent management endpoints -// --------------------------------------------------------------------------- - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -/// Map rtbit torrent state to qBittorrent state string. -fn map_state(state: TorrentStatsState, finished: bool) -> &'static str { - match (state, finished) { - (TorrentStatsState::Initializing, _) => "metaDL", - (TorrentStatsState::Live, false) => "downloading", - (TorrentStatsState::Live, true) => "uploading", - (TorrentStatsState::Paused, false) => "pausedDL", - (TorrentStatsState::Paused, true) => "pausedUP", - (TorrentStatsState::Error, _) => "error", - } -} - -#[derive(Deserialize, Default)] -#[allow(dead_code)] -struct TorrentsInfoQuery { - filter: Option, - category: Option, - hashes: Option, - sort: Option, - reverse: Option, - limit: Option, - offset: Option, -} - -/// Check if a torrent matches the qBit filter. -fn matches_filter( - filter: &str, - qbit_state: &str, - stats: &crate::torrent_state::stats::TorrentStats, -) -> bool { - match filter { - "all" => true, - "downloading" => qbit_state == "downloading" || qbit_state == "metaDL", - "seeding" => qbit_state == "uploading", - "completed" => stats.finished, - "paused" => qbit_state == "pausedDL" || qbit_state == "pausedUP", - "active" => qbit_state == "downloading" || qbit_state == "uploading", - "inactive" => qbit_state != "downloading" && qbit_state != "uploading", - "resumed" => qbit_state != "pausedDL" && qbit_state != "pausedUP", - "stalled" | "stalled_uploading" | "stalled_downloading" => { - matches!(stats.state, TorrentStatsState::Live) - && stats - .live - .as_ref() - .map(|l| l.download_speed.mbps < 0.001 && l.upload_speed.mbps < 0.001) - .unwrap_or(true) - } - "errored" => qbit_state == "error", - _ => true, - } -} - -async fn h_torrents_info( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let api = &state.api_state.api; - let now = now_unix(); - - let hash_filter: Option> = query - .hashes - .as_ref() - .map(|h| h.split('|').map(|s| s.to_lowercase()).collect()); - - let mut torrents: Vec = api.session().with_torrents(|iter| { - iter.filter_map(|(id, handle)| { - let info_hash = handle.shared().info_hash.as_string(); - - // Filter by hash if specified - if let Some(ref hashes) = hash_filter - && !hashes.contains(&info_hash) - { - return None; - } - - let stats = handle.stats(); - let name = handle.name().unwrap_or_else(|| format!("torrent_{id}")); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(); - let content_path = format!("{}/{}", output_folder, name); - - let qbit_state = map_state(stats.state, stats.finished); - - // Apply filter - if let Some(ref filter) = query.filter - && !matches_filter(filter, qbit_state, &stats) - { - return None; - } - - let dl_speed = stats - .live - .as_ref() - .map(|l| l.download_speed.as_bytes()) - .unwrap_or(0); - let up_speed = stats - .live - .as_ref() - .map(|l| l.upload_speed.as_bytes()) - .unwrap_or(0); - - let progress = if stats.total_bytes > 0 { - stats.progress_bytes as f64 / stats.total_bytes as f64 - } else { - 0.0 - }; - - let eta = if dl_speed > 0 { - let remaining = stats.total_bytes.saturating_sub(stats.progress_bytes); - let eta_secs = remaining / dl_speed; - // Cap at 100 days to avoid overflow; i64::try_from handles u64 > i64::MAX - i64::try_from(eta_secs.min(8_640_000)).unwrap_or(8_640_000i64) - } else { - 8_640_000i64 - }; - - let num_seeds = stats - .live - .as_ref() - .map(|l| l.snapshot.peer_stats.live) - .unwrap_or(0); - - let tracker = handle - .shared() - .trackers - .iter() - .next() - .map(|u| u.to_string()) - .unwrap_or_default(); - - let trackers_count = handle.shared().trackers.len(); - - Some(QbitTorrentInfo { - added_on: now, - amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), - auto_tmm: false, - availability: -1, - category: String::new(), - completed: stats.progress_bytes, - completion_on: if stats.finished { - i64::try_from(now).unwrap_or(i64::MAX) - } else { - -1 - }, - content_path, - dl_limit: -1, - dlspeed: dl_speed, - download_path: String::new(), - downloaded: stats.progress_bytes, - downloaded_session: 0, - eta, - f_l_piece_prio: false, - force_start: false, - hash: info_hash.clone(), - infohash_v1: info_hash, - infohash_v2: String::new(), - last_activity: now, - magnet_uri: String::new(), - max_ratio: -1, - max_seeding_time: -1, - name, - num_complete: 0, - num_incomplete: 0, - num_leechs: 0, - num_seeds, - priority: 0, - progress, - ratio: 0.0, - ratio_limit: -1, - save_path: output_folder, - seeding_time: 0, - seeding_time_limit: -1, - seen_complete: if stats.finished { - i64::try_from(now).unwrap_or(i64::MAX) - } else { - -1 - }, - seq_dl: false, - size: stats.total_bytes, - state: qbit_state.to_string(), - super_seeding: false, - tags: String::new(), - time_active: 0, - total_size: stats.total_bytes, - tracker, - trackers_count, - up_limit: -1, - uploaded: stats.uploaded_bytes, - uploaded_session: 0, - upspeed: up_speed, - }) - }) - .collect() - }); - - // Sort - if let Some(ref sort_field) = query.sort { - let reverse = query.reverse.unwrap_or(false); - torrents.sort_by(|a, b| { - let cmp = match sort_field.as_str() { - "name" => a.name.cmp(&b.name), - "size" | "total_size" => a.total_size.cmp(&b.total_size), - "progress" => a - .progress - .partial_cmp(&b.progress) - .unwrap_or(std::cmp::Ordering::Equal), - "dlspeed" => a.dlspeed.cmp(&b.dlspeed), - "upspeed" => a.upspeed.cmp(&b.upspeed), - "eta" => a.eta.cmp(&b.eta), - "state" => a.state.cmp(&b.state), - "added_on" => a.added_on.cmp(&b.added_on), - "hash" => a.hash.cmp(&b.hash), - "downloaded" => a.downloaded.cmp(&b.downloaded), - "uploaded" => a.uploaded.cmp(&b.uploaded), - "ratio" => a - .ratio - .partial_cmp(&b.ratio) - .unwrap_or(std::cmp::Ordering::Equal), - _ => std::cmp::Ordering::Equal, - }; - if reverse { cmp.reverse() } else { cmp } - }); - } - - // Offset and limit - let offset = query.offset.unwrap_or(0); - if offset > 0 && offset < torrents.len() { - torrents = torrents.split_off(offset); - } - if let Some(limit) = query.limit { - torrents.truncate(limit); - } - - axum::Json(torrents) -} - -#[derive(Deserialize)] -struct HashQuery { - hash: String, -} - -async fn h_torrents_properties( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let api = &state.api_state.api; - let idx = match TorrentIdOrHash::parse(&query.hash) { - Ok(idx) => idx, - Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), - }; - - let handle = match api.mgr_handle(idx) { - Ok(h) => h, - Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), - }; - - let stats = handle.stats(); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(); - let now = now_unix(); - - let dl_speed = stats - .live - .as_ref() - .map(|l| l.download_speed.as_bytes()) - .unwrap_or(0); - let up_speed = stats - .live - .as_ref() - .map(|l| l.upload_speed.as_bytes()) - .unwrap_or(0); - - let eta = if dl_speed > 0 { - let remaining = stats.total_bytes.saturating_sub(stats.progress_bytes); - let eta_secs = remaining / dl_speed; - i64::try_from(eta_secs).unwrap_or(8640000i64) - } else { - 8640000i64 - }; - - let piece_size = handle - .with_metadata(|m| m.info.lengths().default_piece_length() as u64) - .unwrap_or(0); - let pieces_num = handle - .with_metadata(|m| m.info.lengths().total_pieces()) - .unwrap_or(0); - - axum::Json(QbitTorrentProperties { - save_path: output_folder, - creation_date: now, - piece_size, - comment: String::new(), - total_wasted: 0, - total_uploaded: stats.uploaded_bytes, - total_uploaded_session: 0, - total_downloaded: stats.progress_bytes, - total_downloaded_session: 0, - up_limit: -1, - dl_limit: -1, - time_elapsed: 0, - seeding_time: 0, - nb_connections: 0, - nb_connections_limit: -1, - share_ratio: 0.0, - addition_date: now, - completion_date: if stats.finished { - i64::try_from(now).unwrap_or(i64::MAX) - } else { - -1 - }, - created_by: String::new(), - dl_speed_avg: dl_speed, - dl_speed, - eta, - last_seen: now, - peers: 0, - peers_total: 0, - pieces_have: 0, - pieces_num, - reannounce: 0, - seeds: 0, - seeds_total: 0, - total_size: stats.total_bytes, - up_speed_avg: up_speed, - up_speed, - }) - .into_response() -} - -async fn h_torrents_files( - State(state): State>, - Query(query): Query, -) -> impl IntoResponse { - let api = &state.api_state.api; - let idx = match TorrentIdOrHash::parse(&query.hash) { - Ok(idx) => idx, - Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), - }; - - let handle = match api.mgr_handle(idx) { - Ok(h) => h, - Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), - }; - - let details = match api.api_torrent_details(idx) { - Ok(d) => d, - Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), - }; - - let stats = handle.stats(); - let is_seed = stats.finished; - - let files: Vec = details - .files - .unwrap_or_default() - .iter() - .enumerate() - .map(|(i, f)| { - let file_progress = stats.file_progress.get(i).copied().unwrap_or(0); - let progress = if f.length > 0 { - file_progress as f64 / f.length as f64 - } else { - 1.0 - }; - QbitFileInfo { - index: i, - name: f.name.clone(), - size: f.length, - progress, - priority: if f.included { 1 } else { 0 }, - is_seed, - piece_range: [0, 0], - availability: if progress >= 1.0 { 1.0 } else { progress }, - } - }) - .collect(); - - axum::Json(files).into_response() -} - -// --------------------------------------------------------------------------- -// Torrent actions (add, pause, resume, delete) -// --------------------------------------------------------------------------- - -async fn h_torrents_add( - State(state): State>, - mut multipart: Multipart, -) -> impl IntoResponse { - let mut urls: Vec = Vec::new(); - let mut torrent_bytes: Vec = Vec::new(); - let mut savepath: Option = None; - let mut paused = false; - - while let Ok(Some(field)) = multipart.next_field().await { - let field_name = field.name().unwrap_or("").to_string(); - match field_name.as_str() { - "urls" => { - if let Ok(text) = field.text().await { - for line in text.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() && urls.len() < 100 { - urls.push(trimmed.to_string()); - } - } - } - } - "torrents" => { - if torrent_bytes.len() >= 100 { - continue; - } - if let Ok(data) = field.bytes().await - && !data.is_empty() - { - torrent_bytes.push(data); - } - } - "category" => { - // Accept but don't use for now - let _ = field.text().await; - } - "savepath" => { - if let Ok(text) = field.text().await - && !text.is_empty() - { - savepath = Some(text); - } - } - "paused" => { - if let Ok(text) = field.text().await { - paused = text.eq_ignore_ascii_case("true"); - } - } - _ => { - let _ = field.bytes().await; - } - } - } - - let api = &state.api_state.api; - let mut had_error = false; - - for url in urls { - let opts = AddTorrentOptions { - paused, - overwrite: true, - output_folder: savepath.clone(), - ..Default::default() - }; - if let Err(e) = api - .api_add_torrent(AddTorrent::Url(url.into()), Some(opts)) - .await - { - warn!("qbit compat: error adding torrent URL: {e:#}"); - had_error = true; - } - } - - for data in torrent_bytes { - let opts = AddTorrentOptions { - paused, - overwrite: true, - output_folder: savepath.clone(), - ..Default::default() - }; - if let Err(e) = api - .api_add_torrent(AddTorrent::TorrentFileBytes(data), Some(opts)) - .await - { - warn!("qbit compat: error adding torrent file: {e:#}"); - had_error = true; - } - } - - if had_error { - (StatusCode::INTERNAL_SERVER_ERROR, "Error adding torrent(s)") - } else { - (StatusCode::OK, "Ok.") - } -} - -#[derive(Deserialize, Default)] -struct HashesForm { - #[serde(default)] - hashes: String, -} - -/// Resolve hash(es) from form body. "all" means all torrents. -fn resolve_hashes(api: &Api, hashes_str: &str) -> Vec { - if hashes_str == "all" { - api.session().with_torrents(|iter| { - iter.map(|(_, handle)| TorrentIdOrHash::Hash(handle.shared().info_hash)) - .collect() - }) - } else { - hashes_str - .split('|') - .filter_map(|h| { - let h = h.trim(); - if h.is_empty() { - return None; - } - TorrentIdOrHash::parse(h).ok() - }) - .collect() - } -} - -async fn h_torrents_pause(State(state): State>, body: Bytes) -> &'static str { - let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - let api = &state.api_state.api; - let hashes = resolve_hashes(api, &form.hashes); - - for idx in hashes { - if let Err(e) = api.api_torrent_action_pause(idx).await { - warn!("qbit compat: error pausing torrent {idx}: {e:#}"); - } - } - - "Ok." -} - -async fn h_torrents_resume(State(state): State>, body: Bytes) -> &'static str { - let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - let api = &state.api_state.api; - let hashes = resolve_hashes(api, &form.hashes); - - for idx in hashes { - if let Err(e) = api.api_torrent_action_start(idx).await { - warn!("qbit compat: error resuming torrent {idx}: {e:#}"); - } - } - - "Ok." -} - -#[derive(Deserialize, Default)] -struct DeleteForm { - #[serde(default)] - hashes: String, - #[serde(default, alias = "deleteFiles")] - delete_files: Option, -} - -async fn h_torrents_delete(State(state): State>, body: Bytes) -> &'static str { - let form: DeleteForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - let api = &state.api_state.api; - let hashes = resolve_hashes(api, &form.hashes); - let delete_files = form - .delete_files - .as_deref() - .is_some_and(|s| s.eq_ignore_ascii_case("true")); - - for idx in hashes { - let result = if delete_files { - api.api_torrent_action_delete(idx).await - } else { - api.api_torrent_action_forget(idx).await - }; - if let Err(e) = result { - warn!("qbit compat: error deleting torrent {idx}: {e:#}"); - } - } - - "Ok." -} - -#[derive(Deserialize, Default)] -#[allow(dead_code)] -struct SetCategoryForm { - #[serde(default)] - hashes: String, - #[serde(default)] - category: String, -} - -async fn h_torrents_set_category( - State(_state): State>, - body: Bytes, -) -> &'static str { - // Categories are not persisted per-torrent in rtbit. - // Accept silently so *arr apps don't error out. - let _form: SetCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - "Ok." -} - -// --------------------------------------------------------------------------- -// Category endpoints -// --------------------------------------------------------------------------- - -async fn h_categories(State(state): State>) -> impl IntoResponse { - let cats = state.categories.categories.read(); - let map: HashMap<&str, &QbitCategory> = cats.iter().map(|(k, v)| (k.as_str(), v)).collect(); - axum::Json(serde_json::to_value(&map).unwrap_or_default()) -} - -#[derive(Deserialize, Default)] -struct CreateCategoryForm { - #[serde(default)] - category: String, - #[serde(default, alias = "savePath")] - save_path: String, -} - -async fn h_create_category(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - if form.category.is_empty() { - return (StatusCode::BAD_REQUEST, "Category name required").into_response(); - } - state.categories.categories.write().insert( - form.category.clone(), - QbitCategory { - name: form.category, - save_path: form.save_path, - }, - ); - (StatusCode::OK, "Ok.").into_response() -} - -async fn h_edit_category(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - if form.category.is_empty() { - return (StatusCode::BAD_REQUEST, "Category name required").into_response(); - } - let mut cats = state.categories.categories.write(); - if let Some(cat) = cats.get_mut(&form.category) { - cat.save_path = form.save_path; - } else { - cats.insert( - form.category.clone(), - QbitCategory { - name: form.category, - save_path: form.save_path, - }, - ); - } - (StatusCode::OK, "Ok.").into_response() -} - -#[derive(Deserialize, Default)] -struct RemoveCategoriesForm { - #[serde(default)] - categories: String, -} - -async fn h_remove_categories(State(state): State>, body: Bytes) -> &'static str { - let form: RemoveCategoriesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - let mut cats = state.categories.categories.write(); - for name in form.categories.split('\n') { - let name = name.trim(); - if !name.is_empty() { - cats.remove(name); - } - } - "Ok." -} - -// --------------------------------------------------------------------------- -// Auth middleware helper -// --------------------------------------------------------------------------- - -fn extract_sid(headers: &HeaderMap) -> Option { - headers - .get(http::header::COOKIE) - .and_then(|v| v.to_str().ok()) - .and_then(|cookies| { - cookies.split(';').find_map(|c| { - let c = c.trim(); - c.strip_prefix("SID=").map(|s| s.to_string()) - }) - }) -} - -// --------------------------------------------------------------------------- -// Router construction -// --------------------------------------------------------------------------- - -/// Build the qBittorrent v2 API router. Should be nested at `/api/v2`. -pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { - let qbit_state = Arc::new(QbitState { - api_state: api_state.clone(), - sessions: QbitSessions::new(), - categories: QbitCategories::new(), - }); - - // Auth endpoints (no auth required to reach these) - let auth_router = Router::new() - .route("/login", post(h_auth_login)) - .route("/logout", post(h_auth_logout)); - - // App info endpoints - let app_router = Router::new() - .route("/version", get(h_app_version)) - .route("/webapiVersion", get(h_app_webapi_version)) - .route("/buildInfo", get(h_app_build_info)) - .route("/preferences", get(h_app_preferences)); - - // Torrent endpoints - let torrents_router = Router::new() - .route("/info", get(h_torrents_info)) - .route("/properties", get(h_torrents_properties)) - .route("/files", get(h_torrents_files)) - .route("/add", post(h_torrents_add)) - .route("/pause", post(h_torrents_pause)) - .route("/resume", post(h_torrents_resume)) - .route("/delete", post(h_torrents_delete)) - .route("/setCategory", post(h_torrents_set_category)) - .route("/categories", get(h_categories)) - .route("/createCategory", post(h_create_category)) - .route("/editCategory", post(h_edit_category)) - .route("/removeCategories", post(h_remove_categories)); - - // Transfer info - let transfer_router = Router::new().route("/info", get(h_transfer_info)); - - // Protected endpoints get auth middleware if basic_auth is configured - let has_auth = api_state.opts.basic_auth.is_some(); - - let mut protected_router = Router::new() - .nest("/app", app_router) - .nest("/torrents", torrents_router) - .nest("/transfer", transfer_router); - - if has_auth { - let qbit_state_for_layer = qbit_state.clone(); - protected_router = protected_router.route_layer(axum::middleware::from_fn( - move |headers: HeaderMap, - request: axum::extract::Request, - next: axum::middleware::Next| { - let qbit_state = qbit_state_for_layer.clone(); - async move { - let sid = extract_sid(&headers); - let valid = sid - .as_deref() - .is_some_and(|s| qbit_state.sessions.validate_session(s)); - if valid { - Ok(next.run(request).await) - } else { - Err((StatusCode::FORBIDDEN, "Not authenticated")) - } - } - }, - )); - } - - Router::new() - .nest("/auth", auth_router) - .merge(protected_router) - .with_state(qbit_state) -} - -#[cfg(test)] -mod tests { - #[test] - fn test_eta_overflow_safety() { - // Simulate: very large remaining bytes / very small download speed - // This should not panic, and should clamp to a safe fallback. - let remaining: u64 = u64::MAX; - let dl_speed: u64 = 1; - let eta_secs = remaining / dl_speed; - let eta = i64::try_from(eta_secs).unwrap_or(8640000i64); - assert_eq!(eta, 8640000i64, "should clamp to fallback on overflow"); - - // Large but within i64 range - let remaining: u64 = 1_000_000_000_000; - let dl_speed: u64 = 100; - let eta_secs = remaining / dl_speed; - let eta = i64::try_from(eta_secs).unwrap_or(8640000i64); - assert_eq!( - eta, 10_000_000_000i64, - "should return exact ETA when it fits in i64" - ); - - // Zero speed: the calling code uses a guard, but verify the fallback path - let dl_speed: u64 = 0; - let eta = if dl_speed > 0 { - let remaining: u64 = 1_000_000; - let eta_secs = remaining / dl_speed; - i64::try_from(eta_secs).unwrap_or(8640000i64) - } else { - 8640000i64 - }; - assert_eq!(eta, 8640000i64, "zero speed should return 8640000"); - } - - #[test] - fn test_timestamp_cast_safety() { - // Current unix timestamp fits in i64, but test the boundary - let now: u64 = i64::MAX as u64 + 1; - let result = i64::try_from(now).unwrap_or(i64::MAX); - assert_eq!(result, i64::MAX, "should clamp to i64::MAX on overflow"); - - // Normal timestamp should pass through - let now: u64 = 1_700_000_000; - let result = i64::try_from(now).unwrap_or(i64::MAX); - assert_eq!(result, 1_700_000_000i64); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/rss.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/rss.rs deleted file mode 100644 index 38c59acb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/rss.rs +++ /dev/null @@ -1,239 +0,0 @@ -use axum::{ - Json, - extract::{Path, Query, State}, - response::IntoResponse, -}; -use serde::Deserialize; - -use super::ApiState; -use crate::{ - ApiError, - api::Result, - rss::{config::RssFeedConfig, models::RssRule}, -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn rss_db( - state: &ApiState, -) -> Result<&std::sync::Arc>> { - state - .opts - .rss_db - .as_ref() - .ok_or_else(|| ApiError::from(anyhow::anyhow!("RSS is not enabled"))) -} - -// --------------------------------------------------------------------------- -// RSS feed config handlers -// --------------------------------------------------------------------------- - -/// GET /rss/feeds -- List RSS feeds. -pub async fn h_rss_feeds_list(State(state): State) -> Result { - let db = rss_db(&state)?; - let feeds = db.lock().feed_list().map_err(ApiError::from)?; - Ok(Json(feeds)) -} - -/// POST /rss/feeds -- Add an RSS feed. -pub async fn h_rss_feed_add( - State(state): State, - Json(feed): Json, -) -> Result { - let db = rss_db(&state)?; - let db = db.lock(); - let existing = db.feed_list().map_err(ApiError::from)?; - if existing.iter().any(|f| f.name == feed.name) { - return Err(ApiError::from(anyhow::anyhow!( - "Feed '{}' already exists", - feed.name - ))); - } - db.feed_upsert(&feed).map_err(ApiError::from)?; - Ok(Json(serde_json::json!({"status": true}))) -} - -/// PUT /rss/feeds/{name} -- Update an RSS feed. -pub async fn h_rss_feed_update( - State(state): State, - Path(name): Path, - Json(feed): Json, -) -> Result { - let db = rss_db(&state)?; - let db = db.lock(); - // If the name changed, delete the old one - if feed.name != name { - db.feed_delete(&name).map_err(ApiError::from)?; - } - db.feed_upsert(&feed).map_err(ApiError::from)?; - Ok(Json(serde_json::json!({"status": true}))) -} - -/// DELETE /rss/feeds/{name} -- Delete an RSS feed. -pub async fn h_rss_feed_delete( - State(state): State, - Path(name): Path, -) -> Result { - let db = rss_db(&state)?; - db.lock().feed_delete(&name).map_err(ApiError::from)?; - Ok(Json(serde_json::json!({"status": true}))) -} - -// --------------------------------------------------------------------------- -// RSS item handlers -// --------------------------------------------------------------------------- - -#[derive(Deserialize, Default)] -pub struct RssItemsQuery { - pub feed: Option, - pub limit: Option, -} - -/// GET /rss/items -- List RSS feed items. -pub async fn h_rss_items_list( - State(state): State, - Query(q): Query, -) -> Result { - let db = rss_db(&state)?; - let limit = q.limit.unwrap_or(500); - let items = db - .lock() - .rss_items_list(q.feed.as_deref(), limit) - .map_err(ApiError::from)?; - Ok(Json(items)) -} - -/// POST /rss/items/{id}/download -- Download a specific RSS feed item as a torrent. -pub async fn h_rss_item_download( - State(state): State, - Path(id): Path, -) -> Result { - let rss = rss_db(&state)?; - - let item = { - let db = rss.lock(); - db.rss_item_get(&id) - .map_err(ApiError::from)? - .ok_or_else(|| ApiError::from(anyhow::anyhow!("RSS item not found")))? - }; - - let url = item - .url - .as_ref() - .ok_or_else(|| ApiError::from(anyhow::anyhow!("No download URL for this item")))?; - - // Add the torrent via the session - use crate::session::{AddTorrent, AddTorrentOptions}; - use std::borrow::Cow; - - let opts = AddTorrentOptions { - overwrite: true, - category: item.category.clone(), - ..Default::default() - }; - - state - .api - .session() - .add_torrent(AddTorrent::Url(Cow::Owned(url.clone())), Some(opts)) - .await - .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to add torrent: {:#}", e)))?; - - // Mark as downloaded - { - let db = rss.lock(); - let _ = db.rss_item_mark_downloaded(&id, item.category.as_deref()); - } - - Ok(Json(serde_json::json!({"status": true}))) -} - -// --------------------------------------------------------------------------- -// RSS rule handlers -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -pub struct RssRuleBody { - pub name: String, - pub feed_names: Vec, - pub category: Option, - pub priority: Option, - pub match_regex: String, - pub enabled: Option, -} - -/// GET /rss/rules -- List RSS download rules. -pub async fn h_rss_rules_list(State(state): State) -> Result { - let db = rss_db(&state)?; - let rules = db.lock().rss_rule_list().map_err(ApiError::from)?; - Ok(Json(rules)) -} - -/// POST /rss/rules -- Add an RSS download rule. -pub async fn h_rss_rule_add( - State(state): State, - Json(body): Json, -) -> Result { - // Validate the regex - regex::Regex::new(&body.match_regex) - .map_err(|e| ApiError::from(anyhow::anyhow!("Invalid regex: {}", e)))?; - - let rule = RssRule { - id: uuid::Uuid::new_v4().to_string(), - name: body.name, - feed_names: body.feed_names, - category: body.category, - priority: body.priority.unwrap_or(1), - match_regex: body.match_regex, - enabled: body.enabled.unwrap_or(true), - }; - let db = rss_db(&state)?; - db.lock().rss_rule_insert(&rule).map_err(ApiError::from)?; - Ok(Json(serde_json::json!({"status": true}))) -} - -/// PUT /rss/rules/{id} -- Update an RSS download rule. -pub async fn h_rss_rule_update( - State(state): State, - Path(id): Path, - Json(body): Json, -) -> Result { - regex::Regex::new(&body.match_regex) - .map_err(|e| ApiError::from(anyhow::anyhow!("Invalid regex: {}", e)))?; - - let rule = RssRule { - id, - name: body.name, - feed_names: body.feed_names, - category: body.category, - priority: body.priority.unwrap_or(1), - match_regex: body.match_regex, - enabled: body.enabled.unwrap_or(true), - }; - let db = rss_db(&state)?; - db.lock().rss_rule_update(&rule).map_err(ApiError::from)?; - Ok(Json(serde_json::json!({"status": true}))) -} - -/// DELETE /rss/rules/{id} -- Delete an RSS download rule. -pub async fn h_rss_rule_delete( - State(state): State, - Path(id): Path, -) -> Result { - let db = rss_db(&state)?; - db.lock().rss_rule_delete(&id).map_err(ApiError::from)?; - Ok(Json(serde_json::json!({"status": true}))) -} - -// --------------------------------------------------------------------------- -// RSS settings handler -// --------------------------------------------------------------------------- - -/// GET /rss/settings -- Get RSS settings. -pub async fn h_rss_settings_get(State(state): State) -> Result { - Ok(Json(serde_json::json!({ - "rss_history_limit": state.opts.rss_history_limit.unwrap_or(500), - }))) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/streaming.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/streaming.rs deleted file mode 100644 index 03273a59..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/streaming.rs +++ /dev/null @@ -1,137 +0,0 @@ -use std::{io::SeekFrom, sync::Arc}; - -use anyhow::Context; -use axum::{ - extract::{Path, State}, - response::IntoResponse, -}; -use bytes::Bytes; -use http::{HeaderMap, HeaderValue, StatusCode}; -use serde::Deserialize; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt}; -use tracing::{debug, trace}; - -use super::ApiState; -use crate::{ - WithStatus, - api::{Result, TorrentIdOrHash}, -}; - -#[derive(Deserialize)] -pub struct StreamPathParams { - id: TorrentIdOrHash, - file_id: usize, - #[serde(rename = "filename")] - _filename: Option>, -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/stream/{file_id}", - params( - ("id" = String, Path, description = "Torrent ID or info hash"), - ("file_id" = usize, Path, description = "File index") - ), - responses( - (status = 200, description = "File stream with Range header support"), - (status = 206, description = "Partial content (range request)") - ) -))] -pub async fn h_torrent_stream_file( - State(state): State, - Path(StreamPathParams { id, file_id, .. }): Path, - headers: http::HeaderMap, -) -> Result { - trace!(?id, ?file_id, "acquiring stream"); - let mut stream = state.api.api_stream(id, file_id).await?; - let mut status = StatusCode::OK; - let mut output_headers = HeaderMap::new(); - output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes")); - - const DLNA_TRANSFER_MODE: &str = "transferMode.dlna.org"; - const DLNA_GET_CONTENT_FEATURES: &str = "getcontentFeatures.dlna.org"; - const DLNA_CONTENT_FEATURES: &str = "contentFeatures.dlna.org"; - - if headers - .get(DLNA_TRANSFER_MODE) - .map(|v| matches!(v.as_bytes(), b"Streaming" | b"streaming")) - .unwrap_or(false) - { - output_headers.insert(DLNA_TRANSFER_MODE, HeaderValue::from_static("Streaming")); - } - - if headers - .get(DLNA_GET_CONTENT_FEATURES) - .map(|v| v.as_bytes() == b"1") - .unwrap_or(false) - { - output_headers.insert( - DLNA_CONTENT_FEATURES, - HeaderValue::from_static("DLNA.ORG_OP=01"), - ); - } - - if let Ok(mime) = state.api.torrent_file_mime_type(id, file_id) { - output_headers.insert(http::header::CONTENT_TYPE, HeaderValue::from_static(mime)); - } - - let range_header = headers.get(http::header::RANGE); - debug!(torrent_id=%id, file_id=file_id, range=?range_header, "request for HTTP stream"); - - let range = range_header - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("bytes=")) - .and_then(|v| v.split_once('-')) - .and_then(|(start, end)| { - let start = start.parse::().ok()?; - let end = if end.is_empty() { - None - } else { - Some(end.parse::().ok()?.saturating_add(1)) - }; - Some((start, end)) - }); - - let stream: Box = if let Some((start, end)) = range { - status = StatusCode::PARTIAL_CONTENT; - - if start >= stream.len() || end.is_some_and(|end| end <= start || end > stream.len()) { - return Err(anyhow::anyhow!("bad range")) - .with_status(StatusCode::RANGE_NOT_SATISFIABLE); - } - - let end = end.unwrap_or(stream.len()); - - stream - .seek(SeekFrom::Start(start)) - .await - .context("error seeking")?; - - let to_take = end - start; - - output_headers.insert( - http::header::CONTENT_LENGTH, - HeaderValue::from_maybe_shared(Bytes::from(to_take.to_string())).unwrap(), - ); - output_headers.insert( - http::header::CONTENT_RANGE, - HeaderValue::from_maybe_shared(Bytes::from(format!( - "bytes {}-{}/{}", - start, - end.saturating_sub(1), - stream.len() - ))) - .unwrap(), - ); - Box::new(stream.take(to_take)) - } else { - output_headers.insert( - http::header::CONTENT_LENGTH, - HeaderValue::from_maybe_shared(Bytes::from(stream.len().to_string())).unwrap(), - ); - Box::new(stream) - }; - - let s = tokio_util::io::ReaderStream::with_capacity(stream, 65536); - Ok((status, (output_headers, axum::body::Body::from_stream(s)))) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/torrents.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/torrents.rs deleted file mode 100644 index 0dba2ab3..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/handlers/torrents.rs +++ /dev/null @@ -1,623 +0,0 @@ -use std::{net::SocketAddr, path::PathBuf, str::FromStr}; - -use anyhow::Context; -use axum::{ - extract::{Path, Query, State}, - response::IntoResponse, -}; -use bytes::Bytes; -use http::{ - HeaderMap, HeaderName, HeaderValue, StatusCode, - header::{CONTENT_DISPOSITION, CONTENT_TYPE}, -}; -use librtbit_core::magnet::Magnet; -use serde::{Deserialize, Serialize}; - -use super::ApiState; -#[cfg(feature = "swagger")] -use crate::api::{ - ApiAddTorrentResponse, EmptyJsonResponse, TorrentDetailsResponse, TorrentListResponse, -}; -use crate::{ - AddTorrent, ApiError, CreateTorrentOptions, SUPPORTED_SCHEMES, - api::{ApiTorrentListOpts, Result, TorrentIdOrHash}, - api_error::WithStatusError, - http_api::timeout::Timeout, - http_api_types::TorrentAddQueryParams, - torrent_state::peer::stats::snapshot::{PeerStatsFilter, PeerStatsFilterState}, - type_aliases::BF, -}; - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents", - params(ApiTorrentListOpts), - responses( - (status = 200, description = "List of torrents", body = TorrentListResponse) - ) -))] -pub async fn h_torrents_list( - State(state): State, - Query(opts): Query, -) -> impl IntoResponse { - axum::Json(state.api.api_torrent_list_ext(opts)) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents", - params(TorrentAddQueryParams), - request_body(content = Vec, description = "Torrent file bytes, magnet link, or URL"), - responses( - (status = 200, description = "Torrent added successfully", body = ApiAddTorrentResponse) - ) -))] -pub async fn h_torrents_post( - State(state): State, - Query(params): Query, - Timeout(timeout): Timeout<600_000, 3_600_000>, - data: Bytes, -) -> Result { - let is_url = params.is_url; - let opts = params.into_add_torrent_options(); - let data = data.to_vec(); - let maybe_magnet = |data: &[u8]| -> bool { - std::str::from_utf8(data) - .ok() - .and_then(|s| Magnet::parse(s).ok()) - .is_some() - }; - let add = match is_url { - Some(true) => AddTorrent::Url( - String::from_utf8(data) - .context("invalid utf-8 for passed URL")? - .into(), - ), - Some(false) => AddTorrent::TorrentFileBytes(data.into()), - - // Guess the format. - None if SUPPORTED_SCHEMES - .iter() - .any(|s| data.starts_with(s.as_bytes())) - || maybe_magnet(&data) => - { - AddTorrent::Url( - String::from_utf8(data) - .context("invalid utf-8 for passed URL")? - .into(), - ) - } - _ => AddTorrent::TorrentFileBytes(data.into()), - }; - tokio::time::timeout(timeout, state.api.api_add_torrent(add, Some(opts))) - .await - .context("timeout")? - .map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent details", body = TorrentDetailsResponse) - ) -))] -pub async fn h_torrent_details( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_torrent_details(idx).map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/haves", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Bitfield of pieces (SVG or binary depending on Accept header)") - ) -))] -pub async fn h_torrent_haves( - State(state): State, - Path(idx): Path, - headers: HeaderMap, -) -> Result { - fn generate_svg(bits: &BF, len: u32) -> String { - if len == 0 { - return r#""# - .to_string(); - } - - const HAVE_COLOR: &str = "#22c55e"; - const MISSING_COLOR: &str = "#374151"; - - let bit_width = 100.0 / len as f64; - let mut svg_segments = String::new(); - - let mut bits_iter = bits.iter().map(|b| *b).enumerate().peekable(); - - while let Some((i, value)) = bits_iter.next() { - let mut count = 1; - - // Peek ahead to find how many subsequent bits have the same value - while let Some((_, next_value)) = bits_iter.peek() { - if *next_value == value { - count += 1; - bits_iter.next(); - } else { - break; - } - } - - let color = if value { HAVE_COLOR } else { MISSING_COLOR }; - let x_pos = i as f64 * bit_width; - let segment_width = count as f64 * bit_width; - - svg_segments.push_str(&format!( - r#""#, - x_pos, segment_width, color - )); - } - - format!( - r#" - {} - "#, - svg_segments - ) - } - - let (bf, len) = state.api.api_dump_haves(idx)?; - - // Check if binary format is requested - let wants_binary = headers - .get(http::header::ACCEPT) - .and_then(|v| v.to_str().ok()) - .is_some_and(|s| s.contains("application/octet-stream")); - - if wants_binary { - let bytes = bf.into_boxed_slice(); - Ok(( - [ - ( - CONTENT_TYPE, - HeaderValue::from_static("application/octet-stream"), - ), - ( - HeaderName::from_static("x-bitfield-len"), - HeaderValue::from_str(&len.to_string()).unwrap(), - ), - ], - bytes, - ) - .into_response()) - } else { - let svg = generate_svg(&bf, len); - Ok(( - [(CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))], - svg, - ) - .into_response()) - } -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/stats", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent stats (v0, deprecated)") - ) -))] -pub async fn h_torrent_stats_v0( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_stats_v0(idx).map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/stats/v1", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent stats (current)") - ) -))] -pub async fn h_torrent_stats_v1( - State(state): State, - Path(idx): Path, -) -> Result { - state.api.api_stats_v1(idx).map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/peer_stats", - params( - ("id" = String, Path, description = "Torrent ID or info hash"), - ("state" = Option, Query, description = "Filter peer state (all or live)") - ), - responses( - (status = 200, description = "Per-peer statistics") - ) -))] -pub async fn h_peer_stats( - State(state): State, - Path(idx): Path, - Query(filter): Query, -) -> Result { - state.api.api_peer_stats(idx, filter).map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/{id}/pause", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent paused", body = EmptyJsonResponse) - ) -))] -pub async fn h_torrent_action_pause( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_pause(idx) - .await - .map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/{id}/start", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent started", body = EmptyJsonResponse) - ) -))] -pub async fn h_torrent_action_start( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_start(idx) - .await - .map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/{id}/forget", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent forgotten (files kept)", body = EmptyJsonResponse) - ) -))] -pub async fn h_torrent_action_forget( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_forget(idx) - .await - .map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/{id}/delete", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Torrent deleted (files removed)", body = EmptyJsonResponse) - ) -))] -pub async fn h_torrent_action_delete( - State(state): State, - Path(idx): Path, -) -> Result { - state - .api - .api_torrent_action_delete(idx) - .await - .map(axum::Json) -} - -#[derive(Deserialize)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct UpdateOnlyFilesRequest { - only_files: Vec, -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/{id}/update_only_files", - params(("id" = String, Path, description = "Torrent ID or info hash")), - request_body(content = UpdateOnlyFilesRequest, description = "File indices to download"), - responses( - (status = 200, description = "File selection updated", body = EmptyJsonResponse) - ) -))] -pub async fn h_torrent_action_update_only_files( - State(state): State, - Path(idx): Path, - axum::Json(req): axum::Json, -) -> Result { - state - .api - .api_torrent_action_update_only_files(idx, &req.only_files.into_iter().collect()) - .await - .map(axum::Json) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/stats", - responses( - (status = 200, description = "Global session stats") - ) -))] -pub async fn h_session_stats(State(state): State) -> impl IntoResponse { - axum::Json(state.api.api_session_stats()) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/peer_stats/prometheus", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Per-peer stats in Prometheus format", body = String) - ) -))] -pub async fn h_peer_stats_prometheus( - State(state): State, - Path(idx): Path, -) -> Result { - let handle = state.api.mgr_handle(idx)?; - - let live = handle - .live() - .with_status_error(StatusCode::PRECONDITION_FAILED, "torrent is not live")?; - - let peer_stats = live.per_peer_stats_snapshot(PeerStatsFilter { - state: PeerStatsFilterState::Live, - }); - - let mut buf = String::new(); - - const NAME: &str = "rtbit_peer_fetched_bytes"; - - use core::fmt::Write; - writeln!(&mut buf, "# TYPE {NAME} counter").unwrap(); - for (addr, stats) in peer_stats.peers.iter() { - // Filter out useless peers that never sent us much. - const THRESHOLD: u64 = 1024 * 1024; - if stats.counters.fetched_bytes >= THRESHOLD { - writeln!( - &mut buf, - "{NAME}{{addr=\"{addr}\"}} {}", - stats.counters.fetched_bytes - THRESHOLD - ) - .unwrap(); - } - } - - Ok(buf) -} - -#[cfg_attr(feature = "swagger", utoipa::path( - get, - path = "/torrents/{id}/metadata", - params(("id" = String, Path, description = "Torrent ID or info hash")), - responses( - (status = 200, description = "Download .torrent file", content_type = "application/x-bittorrent") - ) -))] -pub async fn h_metadata( - State(state): State, - Path(idx): Path, -) -> Result { - let handle = state.api.mgr_handle(idx)?; - - let (filename, bytes) = handle - .with_metadata(|meta| { - ( - meta.info - .name_or_else(|| format!("torrent_{idx}")) - .into_owned(), - meta.torrent_bytes.clone(), - ) - }) - .map_err(ApiError::from)?; - - Ok(( - [( - http::header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{filename}.torrent\""), - )], - bytes, - )) -} - -#[derive(Serialize)] -struct AddPeersResult { - added: usize, -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/{id}/add_peers", - params(("id" = String, Path, description = "Torrent ID or info hash")), - request_body(content = String, description = "Newline-delimited peer socket addresses"), - responses( - (status = 200, description = "Peers added") - ) -))] -pub async fn h_add_peers( - State(state): State, - Path(idx): Path, - body: Bytes, -) -> Result { - let handle = state.api.mgr_handle(idx)?; - let live = handle.live().ok_or(crate::Error::TorrentIsNotLive)?; - - let body = - std::str::from_utf8(&body).with_status_error(StatusCode::BAD_REQUEST, "invalid utf-8")?; - - let addrs = body - .split('\n') - .filter_map(|s| SocketAddr::from_str(s).ok()); - - let mut count = 0; - for addr in addrs { - if live.add_peer_if_not_seen(addr)? { - count += 1; - } - } - - Ok(axum::Json(AddPeersResult { added: count })) -} - -#[derive(Default, Deserialize, Debug)] -enum CreateTorrentOutput { - #[default] - #[serde(rename = "magnet")] - Magnet, - #[serde(rename = "torrent")] - Torrent, -} - -#[derive(Default, Deserialize, Debug)] -pub struct HttpCreateTorrentOptions { - #[serde(default)] - output: CreateTorrentOutput, - #[serde(default)] - trackers: Vec, - name: Option, -} - -#[cfg_attr(feature = "swagger", utoipa::path( - post, - path = "/torrents/create", - request_body(content = String, description = "Local folder path to create torrent from"), - responses( - (status = 200, description = "Torrent created and seeding") - ) -))] -pub async fn h_create_torrent( - State(state): State, - axum_extra::extract::Query(opts): axum_extra::extract::Query, - body: Bytes, -) -> Result { - if !state.opts.allow_create { - return Err(( - StatusCode::FORBIDDEN, - "creating torrents not allowed. Enable through CLI options", - ) - .into()); - } - - let path = std::path::Path::new( - std::str::from_utf8(body.as_ref()) - .with_status_error(StatusCode::BAD_REQUEST, "invalid utf-8")?, - ); - - let create_opts = CreateTorrentOptions { - name: opts.name.as_deref(), - trackers: opts.trackers, - piece_length: None, - }; - - let (torrent, handle) = state - .api - .session() - .create_and_serve_torrent(path, create_opts) - .await?; - - let mut headers = HeaderMap::new(); - if let Ok(v) = HeaderValue::from_str(&handle.id().to_string()) { - headers.insert("torrent-id", v); - } - if let Ok(v) = HeaderValue::from_str(&torrent.info_hash().as_string()) { - headers.insert("torrent-info-hash", v); - } - - match opts.output { - CreateTorrentOutput::Magnet => { - let magnet = torrent.as_magnet(); - Ok(magnet.to_string().into_response()) - } - CreateTorrentOutput::Torrent => { - let name = torrent - .as_info() - .info - .data - .name - .as_ref() - .map(|n| String::from_utf8_lossy(n.as_ref())) - .unwrap_or("torrent".into()); - - headers.insert( - CONTENT_TYPE, - HeaderValue::from_static("application/x-bittorrent"), - ); - - if let Ok(h) = - HeaderValue::from_str(&format!("attachment; filename=\"{name}.torrent\"")) - { - headers.insert(CONTENT_DISPOSITION, h); - } - - Ok((headers, torrent.as_bytes()?).into_response()) - } - } -} - -#[derive(Deserialize)] -pub struct CreateOrEditCategoryRequest { - pub name: String, - pub save_path: Option, -} - -#[derive(Deserialize)] -pub struct SetTorrentCategoryRequest { - pub category: Option, -} - -pub async fn h_list_categories(State(state): State) -> impl IntoResponse { - axum::Json(state.api.api_list_categories()) -} - -pub async fn h_create_or_edit_category( - State(state): State, - axum::Json(req): axum::Json, -) -> Result { - state - .api - .api_create_or_edit_category(req.name, req.save_path) - .await - .map(axum::Json) -} - -pub async fn h_delete_category( - State(state): State, - Path(name): Path, -) -> Result { - state.api.api_remove_category(&name).await.map(axum::Json) -} - -pub async fn h_set_torrent_category( - State(state): State, - Path(idx): Path, - axum::Json(req): axum::Json, -) -> Result { - state - .api - .api_set_torrent_category(idx, req.category) - .await - .map(axum::Json) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/mod.rs deleted file mode 100644 index d62bd4dd..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/mod.rs +++ /dev/null @@ -1,346 +0,0 @@ -use anyhow::Context; -use axum::extract::{ConnectInfo, Request}; -use axum::middleware::Next; -use axum::response::IntoResponse; -#[cfg(any(feature = "webui", feature = "prometheus"))] -use axum::routing::get; -use base64::Engine; -use futures::FutureExt; -use futures::future::BoxFuture; -use http::{HeaderMap, StatusCode}; -use librqbit_dualstack_sockets::TcpListener; -use std::sync::Arc; -use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, OnFailure}; -use tracing::{Span, debug, debug_span, info}; - -use axum::Router; - -use crate::api::Api; -use crate::rss::db::RssDatabase; - -use crate::ApiError; - -pub mod auth; -mod handlers; -mod timeout; -#[cfg(feature = "webui")] -mod webui; - -#[cfg(feature = "swagger")] -#[derive(utoipa::OpenApi)] -#[openapi( - paths( - handlers::torrents::h_torrents_list, - handlers::torrents::h_torrents_post, - handlers::torrents::h_torrent_details, - handlers::torrents::h_torrent_haves, - handlers::torrents::h_torrent_stats_v0, - handlers::torrents::h_torrent_stats_v1, - handlers::torrents::h_peer_stats, - handlers::torrents::h_torrent_action_pause, - handlers::torrents::h_torrent_action_start, - handlers::torrents::h_torrent_action_forget, - handlers::torrents::h_torrent_action_delete, - handlers::torrents::h_torrent_action_update_only_files, - handlers::torrents::h_session_stats, - handlers::torrents::h_peer_stats_prometheus, - handlers::torrents::h_metadata, - handlers::torrents::h_add_peers, - handlers::torrents::h_create_torrent, - handlers::configure::h_update_session_ratelimits, - handlers::configure::h_get_session_ratelimits, - handlers::dht::h_dht_stats, - handlers::dht::h_dht_table, - handlers::logging::h_set_rust_log, - handlers::logging::h_stream_logs, - handlers::streaming::h_torrent_stream_file, - handlers::playlist::h_torrent_playlist, - handlers::playlist::h_global_playlist, - handlers::other::h_resolve_magnet, - ), - components(schemas( - crate::api::TorrentListResponse, - crate::api::TorrentDetailsResponse, - crate::api::TorrentDetailsResponseFile, - crate::api::EmptyJsonResponse, - crate::api::ApiAddTorrentResponse, - crate::limits::LimitsConfig, - handlers::torrents::UpdateOnlyFilesRequest, - )) -)] -struct ApiDoc; - -/// An HTTP server for the API. -pub struct HttpApi { - api: Api, - opts: HttpApiOptions, -} - -#[derive(Default)] -pub struct HttpApiOptions { - pub read_only: bool, - pub basic_auth: Option<(String, String)>, - // Allow creating torrents via API. - pub allow_create: bool, - pub token_store: Option>, - pub credential_store: Option>, - #[cfg(feature = "prometheus")] - pub prometheus_handle: Option, - /// Indexarr integration: base URL (e.g. "http://indexarr:8080"). None = disabled. - pub indexarr_url: Option, - /// Indexarr API key for authenticated requests. - pub indexarr_api_key: Option, - /// RSS database for feed management. None = RSS disabled. - pub rss_db: Option>>, - /// RSS feed history limit (max items to keep). None = unlimited, default 500. - pub rss_history_limit: Option, -} - -/// Constant-time byte comparison to prevent timing attacks on auth credentials. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter() - .zip(b.iter()) - .fold(0u8, |acc, (x, y)| acc | (x ^ y)) - == 0 -} - -impl HttpApi { - pub fn new(api: Api, opts: Option) -> Self { - Self { - api, - opts: opts.unwrap_or_default(), - } - } - - /// Run the HTTP server forever on the given address. - /// If read_only is passed, no state-modifying methods will be exposed. - #[inline(never)] - pub fn make_http_api_and_run( - #[allow(unused_mut)] mut self, - listener: TcpListener, - upnp_router: Option, - ) -> BoxFuture<'static, anyhow::Result<()>> { - #[cfg(feature = "prometheus")] - let mut prometheus_handle = self.opts.prometheus_handle.take(); - - let state = Arc::new(self); - - let mut main_router = handlers::make_api_router(state.clone()); - - #[cfg(feature = "webui")] - { - use axum::response::Redirect; - - let webui_router = webui::make_webui_router(); - main_router = main_router.nest("/web/", webui_router); - main_router = main_router.route("/web", get(|| async { Redirect::permanent("./web/") })) - } - - #[cfg(feature = "swagger")] - { - use utoipa::OpenApi; - use utoipa_swagger_ui::SwaggerUi; - main_router = main_router - .merge(SwaggerUi::new("/swagger").url("/api-docs/openapi.json", ApiDoc::openapi())); - } - - #[cfg(feature = "prometheus")] - if let Some(handle) = prometheus_handle.take() { - let session = state.api.session().clone(); - main_router = main_router.route( - "/metrics", - get(move || async move { - let mut metrics = handle.render(); - session.stats_snapshot().as_prometheus(&mut metrics); - metrics - }), - ); - } - - let cors_layer = { - use tower_http::cors::{AllowHeaders, AllowOrigin}; - - const ALLOWED_ORIGINS: [&[u8]; 4] = [ - // Webui-dev - b"http://localhost:3031", - b"http://127.0.0.1:3031", - // Tauri dev - b"http://localhost:1420", - // Tauri prod - b"tauri://localhost", - ]; - - let allow_regex = std::env::var("CORS_ALLOW_REGEXP") - .ok() - .and_then(|value| regex::bytes::Regex::new(&value).ok()); - - tower_http::cors::CorsLayer::default() - .allow_origin(AllowOrigin::predicate(move |v, _| { - ALLOWED_ORIGINS.contains(&v.as_bytes()) - || allow_regex - .as_ref() - .map(move |r| r.is_match(v.as_bytes())) - .unwrap_or(false) - })) - .allow_headers(AllowHeaders::any()) - }; - - // Unified auth: supports Bearer token, Basic auth, and credential store. - // The middleware checks credentials dynamically so runtime changes are picked up. - { - let token_store = state.opts.token_store.clone(); - let basic_auth = state.opts.basic_auth.clone(); - let credential_store = state.opts.credential_store.clone(); - - let has_initial_creds = basic_auth.is_some() - || credential_store - .as_ref() - .is_some_and(|cs| cs.has_credentials()); - - if has_initial_creds { - info!("Enabling authentication in HTTP API"); - } else if credential_store.is_some() { - info!("Authentication not yet configured; setup required via /auth/setup"); - } - - main_router = main_router.route_layer(axum::middleware::from_fn( - move |headers: HeaderMap, request: axum::extract::Request, next: Next| { - let token_store = token_store.clone(); - let basic_auth = basic_auth.clone(); - let credential_store = credential_store.clone(); - async move { - // Always skip auth for these public endpoints - let path = request.uri().path(); - if path == "/auth/login" - || path == "/auth/refresh" - || path == "/auth/status" - || path == "/auth/setup" - { - return Ok(next.run(request).await); - } - - // Determine if any credentials are configured - let has_stored_creds = credential_store - .as_ref() - .is_some_and(|cs| cs.has_credentials()); - let has_env_creds = basic_auth.is_some(); - - // If no credentials configured anywhere, allow all requests through - // (setup_required state — user needs to set up auth first) - if !has_stored_creds && !has_env_creds { - return Ok(next.run(request).await); - } - - // Try Bearer token first - if let Some(ts) = &token_store - && let Some(token) = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) - { - if ts.validate_access_token(token) { - return Ok(next.run(request).await); - } - return Err(ApiError::unauthorized()); - } - - // Try Basic auth — check credential store first, then env var - let user_pass = headers - .get("Authorization") - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Basic ")) - .and_then(|v| base64::engine::general_purpose::STANDARD.decode(v).ok()) - .and_then(|v| String::from_utf8(v).ok()); - - let user_pass = match user_pass { - Some(up) => up, - None => { - return Ok(( - StatusCode::UNAUTHORIZED, - [("WWW-Authenticate", "Basic realm=\"API\"")], - ) - .into_response()); - } - }; - - if let Some((u, p)) = user_pass.split_once(':') { - // Check credential store - if has_stored_creds - && let Some(cs) = &credential_store - && cs.validate(u, p) - { - return Ok(next.run(request).await); - } - - // Check env var credentials - if let Some((env_u, env_p)) = &basic_auth - && constant_time_eq(u.as_bytes(), env_u.as_bytes()) - && constant_time_eq(p.as_bytes(), env_p.as_bytes()) - { - return Ok(next.run(request).await); - } - } - - Err(ApiError::unauthorized()) - } - }, - )); - } - - // qBittorrent WebUI API v2 compatibility layer. - // Mounted after auth so it handles its own cookie-based auth. - { - let qbit_router = handlers::qbit_compat::make_qbit_router(state.clone()); - main_router = main_router.nest("/api/v2", qbit_router); - } - - if let Some(upnp_router) = upnp_router { - main_router = main_router.nest("/upnp", upnp_router); - } - - let app = main_router - .layer(axum::extract::DefaultBodyLimit::max(100 * 1024 * 1024)) // 100 MB max request body - .layer(cors_layer) - .layer( - tower_http::trace::TraceLayer::new_for_http() - .make_span_with(|req: &Request| { - let method = req.method(); - let uri = req.uri(); - if let Some(ConnectInfo(addr)) = req - .extensions() - .get::>() - { - debug_span!("request", %method, %uri, addr=%addr.0) - } else { - debug_span!("request", %method, %uri) - } - }) - .on_request(|req: &Request, _: &Span| { - if req.uri().path().starts_with("/upnp") { - debug!(headers=?req.headers()) - } - }) - .on_response(DefaultOnResponse::new().include_headers(true)) - .on_failure({ - let mut default = DefaultOnFailure::new(); - move |failure_class, latency, span: &Span| match failure_class { - tower_http::classify::ServerErrorsFailureClass::StatusCode( - StatusCode::NOT_IMPLEMENTED, - ) => {} - _ => default.on_failure(failure_class, latency, span), - } - }), - ) - .into_make_service_with_connect_info::(); - - async move { - axum::serve(listener, app) - .await - .context("error running HTTP API") - } - .boxed() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/timeout.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/timeout.rs deleted file mode 100644 index 53728453..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/timeout.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::time::Duration; - -use anyhow::Context; -use axum::{RequestPartsExt, extract::Query}; -use http::request::Parts; -use serde::Deserialize; - -use crate::ApiError; - -pub struct Timeout(pub Duration); - -impl axum::extract::FromRequestParts - for Timeout -where - S: Send + Sync, -{ - type Rejection = ApiError; - - /// Perform the extraction. - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - #[derive(Deserialize)] - struct QueryT { - timeout_ms: Option, - } - - let q = parts - .extract::>() - .await - .context("error running Timeout extractor")?; - - let timeout_ms = q - .timeout_ms - .map(Ok) - .or_else(|| { - parts - .headers - .get("x-req-timeout-ms") - .map(|v| { - std::str::from_utf8(v.as_bytes()).context("invalid utf-8 in timeout value") - }) - .map(|v| v.and_then(|v| v.parse::().context("invalid timeout integer"))) - }) - .transpose() - .context("error parsing timeout")? - .unwrap_or(DEFAULT_MS); - let timeout_ms = timeout_ms.min(MAX_MS); - Ok(Timeout(Duration::from_millis(timeout_ms as u64))) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/webui.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/webui.rs deleted file mode 100644 index ebe8635e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api/webui.rs +++ /dev/null @@ -1,41 +0,0 @@ -use axum::{Router, routing::get}; - -pub fn make_webui_router() -> Router { - Router::new() - .route( - "/", - get(|| async { - ( - [("Content-Type", "text/html")], - include_str!("../../webui/dist/index.html"), - ) - }), - ) - .route( - "/assets/index.js", - get(|| async { - ( - [("Content-Type", "application/javascript")], - include_str!("../../webui/dist/assets/index.js"), - ) - }), - ) - .route( - "/assets/index.css", - get(|| async { - ( - [("Content-Type", "text/css")], - include_str!("../../webui/dist/assets/index.css"), - ) - }), - ) - .route( - "/assets/logo.svg", - get(|| async { - ( - [("Content-Type", "image/svg+xml")], - include_str!("../../webui/dist/assets/logo.svg"), - ) - }), - ) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api_client.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api_client.rs deleted file mode 100644 index 982d6d38..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api_client.rs +++ /dev/null @@ -1,119 +0,0 @@ -use anyhow::Context; -use futures::{FutureExt, future::BoxFuture}; -use serde::Deserialize; - -use crate::{ - api::ApiAddTorrentResponse, - http_api_types::{InitialPeers, TorrentAddQueryParams}, - session::{AddTorrent, AddTorrentOptions}, -}; - -#[derive(Clone)] -pub struct HttpApiClient { - client: reqwest::Client, - base_url: reqwest::Url, -} - -async fn check_response(r: reqwest::Response) -> anyhow::Result { - if r.status().is_success() { - return Ok(r); - } - let status = r.status(); - let url = r.url().clone(); - let body = r - .text() - .await - .with_context(|| format!("cannot read response body for request to {url} ({status})"))?; - - #[derive(Deserialize)] - struct HumanReadableError<'a> { - human_readable: Option<&'a str>, - } - - let human_readable_internal_error = serde_json::from_str::>(&body) - .ok() - .and_then(|e| e.human_readable); - let body_display = human_readable_internal_error.unwrap_or(&body); - - anyhow::bail!("{} -> {}: {}", url, status, body_display) -} - -#[derive(Deserialize)] -struct ApiRoot { - server: String, -} - -async fn json_response( - response: reqwest::Response, -) -> anyhow::Result { - let url = response.url().clone(); - let response = check_response(response).await?; - let body = response.bytes().await?; - let response: T = serde_json::from_slice(&body).with_context(|| { - format!( - "error deserializing response from {:?} as {:?}", - url, - std::any::type_name::(), - ) - })?; - Ok(response) -} - -impl HttpApiClient { - #[inline(never)] - pub fn new(url: &str) -> anyhow::Result { - Ok(Self { - base_url: reqwest::Url::parse(url)?, - client: reqwest::ClientBuilder::new().build()?, - }) - } - - pub fn base_url(&self) -> &reqwest::Url { - &self.base_url - } - - #[inline(never)] - pub fn validate_rtbit_server(&self) -> BoxFuture<'_, anyhow::Result<()>> { - async move { - let response = self.client.get(self.base_url.clone()).send().await?; - let root: ApiRoot = json_response(response).await?; - if root.server == "rtbit" { - return Ok(()); - } - anyhow::bail!("not an rtbit server at {}", &self.base_url) - } - .boxed() - } - - pub fn add_torrent<'a>( - &'a self, - torrent: AddTorrent<'a>, - opts: Option, - ) -> BoxFuture<'a, anyhow::Result> { - async move { - let opts = opts.unwrap_or_default(); - let params = TorrentAddQueryParams { - overwrite: Some(opts.overwrite), - only_files_regex: opts.only_files_regex, - only_files: None, - output_folder: opts.output_folder, - sub_folder: opts.sub_folder, - list_only: Some(opts.list_only), - initial_peers: opts.initial_peers.map(InitialPeers), - ..Default::default() - }; - let qs = serde_urlencoded::to_string(¶ms).unwrap(); - let url = format!("{}torrents?{}", &self.base_url, qs); - let response = check_response( - self.client - .post(&url) - .body(torrent.into_bytes()) - .send() - .await?, - ) - .await?; - json_response(response).await - } - .boxed() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api_types.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api_types.rs deleted file mode 100644 index c83aec5a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/http_api_types.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::{net::SocketAddr, str::FromStr, time::Duration}; - -use itertools::Itertools; -use serde::{Deserialize, Serialize}; - -use crate::{AddTorrentOptions, PeerConnectionOptions}; - -pub struct OnlyFiles(Vec); -pub struct InitialPeers(pub Vec); - -pub use crate::torrent_state::peer::stats::snapshot::{PeerStatsFilter, PeerStatsSnapshot}; - -#[derive(Serialize, Deserialize, Default)] -#[cfg_attr(feature = "swagger", derive(utoipa::IntoParams))] -pub struct TorrentAddQueryParams { - pub overwrite: Option, - pub output_folder: Option, - pub sub_folder: Option, - pub only_files_regex: Option, - #[cfg_attr(feature = "swagger", param(value_type = Option))] - pub only_files: Option, - pub peer_connect_timeout: Option, - pub peer_read_write_timeout: Option, - #[cfg_attr(feature = "swagger", param(value_type = Option))] - pub initial_peers: Option, - pub paused: Option, - /// Will force interpreting the content as a URL. - pub is_url: Option, - pub list_only: Option, -} - -impl Serialize for OnlyFiles { - fn serialize(&self, serializer: S) -> core::result::Result - where - S: serde::Serializer, - { - let s = self.0.iter().map(|id| id.to_string()).join(","); - s.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for OnlyFiles { - fn deserialize(deserializer: D) -> core::result::Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - - let s = String::deserialize(deserializer)?; - let list = s - .split(',') - .try_fold(Vec::::new(), |mut acc, c| match c.parse() { - Ok(i) => { - acc.push(i); - Ok(acc) - } - Err(_) => Err(D::Error::custom(format!( - "only_files: failed to parse {c:?} as integer" - ))), - })?; - if list.is_empty() { - return Err(D::Error::custom( - "only_files: should contain at least one file id", - )); - } - Ok(OnlyFiles(list)) - } -} - -impl<'de> Deserialize<'de> for InitialPeers { - fn deserialize(deserializer: D) -> std::prelude::v1::Result - where - D: serde::Deserializer<'de>, - { - use serde::de::Error; - let string = String::deserialize(deserializer)?; - let mut addrs = Vec::new(); - for addr_str in string.split(',').filter(|s| !s.is_empty()) { - addrs.push(SocketAddr::from_str(addr_str).map_err(D::Error::custom)?); - } - Ok(InitialPeers(addrs)) - } -} - -impl Serialize for InitialPeers { - fn serialize(&self, serializer: S) -> std::prelude::v1::Result - where - S: serde::Serializer, - { - self.0 - .iter() - .map(|s| s.to_string()) - .join(",") - .serialize(serializer) - } -} - -impl TorrentAddQueryParams { - pub fn into_add_torrent_options(self) -> AddTorrentOptions { - AddTorrentOptions { - overwrite: self.overwrite.unwrap_or(false), - paused: self.paused.unwrap_or(false), - only_files_regex: self.only_files_regex, - only_files: self.only_files.map(|o| o.0), - output_folder: self.output_folder, - sub_folder: self.sub_folder, - list_only: self.list_only.unwrap_or(false), - initial_peers: self.initial_peers.map(|i| i.0), - peer_opts: Some(PeerConnectionOptions { - connect_timeout: self.peer_connect_timeout.map(Duration::from_secs), - read_write_timeout: self.peer_read_write_timeout.map(Duration::from_secs), - ..Default::default() - }), - ..Default::default() - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/ip_ranges.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/ip_ranges.rs deleted file mode 100644 index 46c381b1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/ip_ranges.rs +++ /dev/null @@ -1,280 +0,0 @@ -use anyhow::{Context, Result}; -use async_compression::tokio::bufread::GzipDecoder; -use futures::TryStreamExt; -use intervaltree::IntervalTree; -use std::iter::empty; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::ops::Range; -use std::path::Path; -use std::str::FromStr; -use tokio::io::{AsyncBufRead, AsyncRead}; -use tokio::{io::AsyncBufReadExt, io::BufReader}; -use tokio_util::io::StreamReader; -use tracing::trace; -use url::Url; - -struct IntervalTreeWithSize { - t: IntervalTree, - len: usize, -} - -fn interval_tree(it: impl Iterator>) -> IntervalTreeWithSize { - let mut len = 0; - let t = IntervalTree::from_iter(it.map(|r| { - len += 1; - (r, ()) - })); - IntervalTreeWithSize { t, len } -} - -pub struct IpRanges { - // We could store only one interval tree, but splitting them takes less memory, - // as IpAddr is 17 bytes, Ipv4Addr is only 4 bytes (the majority of ranges). - v4: IntervalTreeWithSize, - v6: IntervalTreeWithSize, -} - -impl Default for IpRanges { - fn default() -> Self { - Self { - v4: interval_tree(empty()), - v6: interval_tree(empty()), - } - } -} - -impl IpRanges { - pub fn new( - v4_ranges: impl IntoIterator>, - v6_ranges: impl IntoIterator>, - ) -> Self { - Self { - v4: interval_tree(v4_ranges.into_iter()), - v6: interval_tree(v6_ranges.into_iter()), - } - } - - pub fn len(&self) -> usize { - self.v4.len + self.v6.len - } - - pub async fn load_from_url(url: &str) -> Result { - let parsed_url = Url::parse(url).context("failed to parse URL")?; - - if parsed_url.scheme() == "file" { - let path = parsed_url - .to_file_path() - .ok() - .context("failed to convert file URL to path")?; - return Self::load_from_file(path).await; - } - - let response = reqwest::get(parsed_url) - .await - .context("error fetching list")?; - if !response.status().is_success() { - anyhow::bail!("error fetching list: HTTP {}", response.status()); - } - - let mut reader = StreamReader::new(response.bytes_stream().map_err(std::io::Error::other)); - let bl = Self::create_from_stream(&mut reader).await?; - Ok(bl) - } - - pub async fn load_from_file>(path: P) -> Result { - let mut file = tokio::fs::File::open(path).await?; - Self::create_from_stream(&mut file).await - } - - async fn create_from_stream(reader: &mut (dyn AsyncRead + Unpin + Send)) -> Result { - let mut peek_bytes = [0u8; 2]; - let mut reader = tokio::io::BufReader::new(reader); - - // Peek the first bytes by filling buffer - let buffer = reader.fill_buf().await?; - if buffer.len() >= 2 { - peek_bytes.copy_from_slice(&buffer[0..2]); - } else { - anyhow::bail!("content too short: not enough data to determine compression"); - } - - // Check for Gzip magic bytes - let is_gzip = peek_bytes == [0x1F, 0x8B]; - - if is_gzip { - trace!("detected gzip stream, decompressing"); - Self::create_from_decoded_stream(&mut BufReader::new(GzipDecoder::new(reader))).await - } else { - trace!("plain text file detected."); - Self::create_from_decoded_stream(&mut reader).await - } - } - - async fn create_from_decoded_stream( - reader: &mut (dyn AsyncBufRead + Unpin + Send), - ) -> Result { - let mut v4 = Vec::new(); - let mut v6 = Vec::new(); - - let mut line = String::new(); - - while reader.read_line(&mut line).await? > 0 { - match parse_ip_range(&line) { - Some(IpRange::V4(r)) => { - v4.push(r); - } - Some(IpRange::V6(r)) => { - v6.push(r); - } - None => { - tracing::debug!(line, "couldn't parse line"); - } - } - line.clear(); - } - - Ok(Self::new(v4, v6)) - } - - pub fn has(&self, ip: IpAddr) -> bool { - match ip { - IpAddr::V4(a) => self.v4.t.query_point(a).next().is_some(), - IpAddr::V6(a) => self.v6.t.query_point(a).next().is_some(), - } - } -} - -enum IpRange { - V4(Range), - V6(Range), -} - -fn parse_ip_range(line: &str) -> Option { - let line = line.trim(); - if line.starts_with('#') || line.is_empty() { - return None; - } - - let (_name, ips) = { - let is_ipv6 = line.matches(":").count() > 2; - if is_ipv6 { - line.split_once(':')? - } else { - line.rsplit_once(':')? - } - }; - let (start, end) = ips.split_once('-')?; - match (IpAddr::from_str(start).ok()?, IpAddr::from_str(end).ok()?) { - (IpAddr::V4(start), IpAddr::V4(end)) => { - let end = Ipv4Addr::from_bits(end.to_bits().saturating_add(1)); - Some(IpRange::V4(start..end)) - } - (IpAddr::V6(start), IpAddr::V6(end)) => { - let end = Ipv6Addr::from_bits(end.to_bits().saturating_add(1)); - Some(IpRange::V6(start..end)) - } - _ => None, - } -} - -#[cfg(test)] -mod tests { - use std::io::Cursor; - - use crate::tests::test_util::setup_test_logging; - - use super::*; - use async_compression::tokio::write::GzipEncoder; - use tokio::io::AsyncWriteExt; - - const LIST: &[u8] = br#" - # test - local:192.168.1.1-192.168.1.255 - localv6:2001:db8::1-2001:db8::ffff - "#; - - #[tokio::test] - async fn test_list_gzipped() -> Result<()> { - let mut gzipped_list = Vec::new(); - { - let mut encoder = GzipEncoder::new(&mut gzipped_list); - encoder.write_all(LIST).await.unwrap(); - encoder.flush().await.unwrap(); - encoder.shutdown().await.unwrap(); - } - let list = IpRanges::create_from_stream(&mut Cursor::new(gzipped_list)).await?; - assert!(list.has("192.168.1.1".parse().unwrap())); - assert!(!list.has("8.8.8.8".parse().unwrap())); - - Ok(()) - } - - #[tokio::test] - async fn test_list_plaintext() -> Result<()> { - let list = IpRanges::create_from_stream(&mut Cursor::new(LIST)).await?; - assert!(list.has("192.168.1.1".parse().unwrap())); - assert!(!list.has("8.8.8.8".parse().unwrap())); - - Ok(()) - } - - #[tokio::test] - async fn test_list_from_plaintext_file() -> Result<()> { - // Create a temporary file - let mut temp_file = tokio::fs::File::create("temp_list.txt").await?; - tokio::io::AsyncWriteExt::write_all(&mut temp_file, LIST).await?; - drop(temp_file); // Close the file - - // Load the list from the file - let list = IpRanges::load_from_file("temp_list.txt").await?; - - // Verify the list - assert!(list.has("192.168.1.1".parse().unwrap())); - assert!(!list.has("8.8.8.8".parse().unwrap())); - assert!(list.has("2001:db8::1".parse().unwrap())); - assert!(!list.has("2001:4860:4860::8888".parse().unwrap())); - - // Clean up the temporary file - tokio::fs::remove_file("temp_list.txt").await?; - - Ok(()) - } - - #[test] - fn test_list_empty() { - let list = IpRanges::default(); - assert!(!list.has("127.0.0.1".parse().unwrap())); - assert!(!list.has("::1".parse().unwrap())); - } - - #[test] - fn test_manual_ranges() { - // Add IPv4 range - let start_v4: Ipv4Addr = "192.168.0.0".parse().unwrap(); - let end_v4: Ipv4Addr = "192.168.255.255".parse().unwrap(); - let ipv4_range = start_v4..end_v4; - - // Add IPv6 range - let start_v6: Ipv6Addr = "2001:db8::".parse().unwrap(); - let end_v6: Ipv6Addr = "2001:db8::ffff".parse().unwrap(); - let ipv6_range = start_v6..end_v6; - - let list = IpRanges::new(Some(ipv4_range), Some(ipv6_range)); - // Test IPv4 addresses - assert!(list.has("192.168.1.1".parse().unwrap())); - assert!(!list.has("10.0.0.1".parse().unwrap())); - - // Test IPv6 addresses - assert!(list.has("2001:db8::1".parse().unwrap())); - assert!(!list.has("2001:db9::1".parse().unwrap())); - } - - #[ignore] - #[tokio::test] - async fn test_list_real_url() { - setup_test_logging(); - let _ = IpRanges::load_from_url("https://raw.githubusercontent.com/Naunter/BT_BlockLists/refs/heads/master/bt_blocklists.gz") - .await - .unwrap(); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/lib.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/lib.rs deleted file mode 100644 index babc25e5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/lib.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! -//! This crate provides everything necessary to download [torrents](https://en.wikipedia.org/wiki/BitTorrent). -//! -//! # Quick usage example -//! -//! ```no_run -//! use librtbit::*; -//! -//! tokio_test::block_on(async { -//! let session = Session::new("/tmp/where-to-download".into()).await.unwrap(); -//! let managed_torrent_handle = session.add_torrent( -//! AddTorrent::from_url("magnet:?xt=urn:btih:cab507494d02ebb1178b38f2e9d7be299c86b862"), -//! None // options -//! ).await.unwrap().into_handle().unwrap(); -//! managed_torrent_handle.wait_until_completed().await.unwrap(); -//! }) -//! ``` -//! -//! # Overview -//! The main type to start off with is [`Session`]. -//! -//! It also proved useful to use the [`Api`] when building the rtbit desktop app, as it provides -//! a facade that works with simple serializable types. -//! - -#![warn(clippy::cast_possible_truncation)] - -macro_rules! aframe { - ($e:expr) => {{ - #[cfg(feature = "async-bt")] - { - async_backtrace::frame!($e) - } - #[cfg(not(feature = "async-bt"))] - { - $e - } - }}; -} - -#[macro_use] -mod stat_gen; - -pub mod api; -mod api_error; -mod bitv; -mod bitv_factory; -pub mod category; -mod chunk_tracker; -mod create_torrent_file; -mod dht_utils; -mod error; -pub mod file_info; -mod file_ops; -#[cfg(feature = "http-api")] -pub mod http_api; -#[cfg(feature = "http-api-client")] -pub mod http_api_client; -#[cfg(any(feature = "http-api", feature = "http-api-client"))] -pub mod http_api_types; -mod ip_ranges; -pub mod limits; -mod listen; -mod merge_streams; -mod peer_connection; -mod peer_info_reader; -mod piece_tracker; -mod read_buf; -pub mod rss; -mod session; -mod session_persistence; -pub mod session_stats; -pub mod spawn_utils; - -pub mod storage; -mod stream_connect; -mod torrent_state; -#[cfg(feature = "tracing-subscriber-utils")] -pub mod tracing_subscriber_config_utils; -mod type_aliases; -#[cfg(all(feature = "http-api", feature = "upnp-serve-adapter"))] -pub mod upnp_server_adapter; -mod vectored_traits; -#[cfg(feature = "watch")] -pub mod watch; - -pub use error::{Error, Result}; - -pub use api::Api; -pub use api_error::{ApiError, WithStatus, WithStatusError}; -pub use create_torrent_file::{CreateTorrentOptions, CreateTorrentResult, create_torrent}; -pub use dht; -pub use librtbit_core::spawn_utils::spawn as librtbit_spawn; -pub use listen::{ListenerMode, ListenerOptions}; -pub use peer_connection::PeerConnectionOptions; -pub use session::{ - AddTorrent, AddTorrentOptions, AddTorrentResponse, ListOnlyResponse, SUPPORTED_SCHEMES, - Session, SessionOptions, SessionPersistenceConfig, -}; -pub use stream_connect::ConnectionOptions; -pub use torrent_state::{ - ManagedTorrent, ManagedTorrentShared, ManagedTorrentState, TorrentMetadata, TorrentStats, - TorrentStatsState, -}; -pub use type_aliases::FileInfos; - -pub use buffers::*; -pub use clone_to_owned::CloneToOwned; -pub use librtbit_core::magnet::*; -pub use librtbit_core::peer_id::*; -pub use librtbit_core::torrent_metainfo::*; - -#[cfg(test)] -mod tests; - -/// The cargo version of librtbit. -pub const fn version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -pub const fn client_name_and_version() -> &'static str { - concat!("rtbit ", env!("CARGO_PKG_VERSION")) -} - -pub fn try_increase_nofile_limit() -> anyhow::Result { - Ok(rlimit::increase_nofile_limit(1024 * 1024)?) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/limits.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/limits.rs deleted file mode 100644 index 223952f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/limits.rs +++ /dev/null @@ -1,106 +0,0 @@ -use arc_swap::ArcSwapOption; -use governor::DefaultDirectRateLimiter as RateLimiter; -use governor::Quota; -use serde::Deserialize; -use serde::Serialize; -use std::num::NonZeroU32; -use std::sync::Arc; - -#[derive(Default, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -pub struct LimitsConfig { - #[cfg_attr(feature = "swagger", schema(value_type = Option))] - pub upload_bps: Option, - #[cfg_attr(feature = "swagger", schema(value_type = Option))] - pub download_bps: Option, - pub peer_limit: Option, - pub concurrent_init_limit: Option, -} - -struct Limit { - limiter: ArcSwapOption, - current_bps: std::sync::atomic::AtomicU32, -} - -impl Limit { - fn new_inner(bps: Option) -> Option> { - let bps = bps?; - Some(Arc::new(RateLimiter::direct(Quota::per_second(bps)))) - } - - fn new(bps: Option) -> Self { - use std::sync::atomic::AtomicU32; - Self { - limiter: ArcSwapOption::new(Self::new_inner(bps)), - current_bps: AtomicU32::new(bps.map(|v| v.get()).unwrap_or(0)), - } - } - - async fn acquire(&self, size: NonZeroU32) -> crate::Result<()> { - let lim = self.limiter.load().clone(); - if let Some(rl) = lim.as_ref() { - rl.until_n_ready(size).await?; - } - Ok(()) - } - - fn set(&self, limit: Option) { - use std::sync::atomic::Ordering; - let new = Self::new_inner(limit); - self.limiter.swap(new); - self.current_bps - .store(limit.map(|v| v.get()).unwrap_or(0), Ordering::Relaxed); - } - - fn get(&self) -> Option { - use std::sync::atomic::Ordering; - NonZeroU32::new(self.current_bps.load(Ordering::Relaxed)) - } -} - -pub struct Limits { - down: Limit, - up: Limit, -} - -impl Limits { - pub fn new(config: LimitsConfig) -> Self { - Self { - down: Limit::new(config.download_bps), - up: Limit::new(config.upload_bps), - } - } - - pub async fn prepare_for_upload(&self, len: NonZeroU32) -> crate::Result<()> { - self.up.acquire(len).await - } - - pub async fn prepare_for_download(&self, len: NonZeroU32) -> crate::Result<()> { - self.down.acquire(len).await - } - - pub fn set_upload_bps(&self, bps: Option) { - self.up.set(bps); - } - - pub fn set_download_bps(&self, bps: Option) { - self.down.set(bps); - } - - pub fn get_upload_bps(&self) -> Option { - self.up.get() - } - - pub fn get_download_bps(&self) -> Option { - self.down.get() - } - - pub fn get_config(&self) -> LimitsConfig { - LimitsConfig { - upload_bps: self.get_upload_bps(), - download_bps: self.get_download_bps(), - peer_limit: None, - concurrent_init_limit: None, - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/listen.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/listen.rs deleted file mode 100644 index daf83a85..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/listen.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::{ - net::{Ipv6Addr, SocketAddr}, - sync::Arc, -}; - -use anyhow::Context; -use librqbit_dualstack_sockets::{BindOpts, TcpListener}; -use librqbit_utp::{BindDevice, UtpSocketUdp, UtpSocketUdpOpts}; -use tokio::io::AsyncWrite; -use tokio_util::sync::CancellationToken; -use tracing::info; - -use crate::{stream_connect::ConnectionKind, vectored_traits::AsyncReadVectored}; - -pub(crate) struct ListenResult { - pub tcp_socket: Option, - pub utp_socket: Option>, - pub enable_upnp_port_forwarding: bool, - pub addr: SocketAddr, - pub announce_port: Option, -} - -#[derive(Debug, Clone, Copy)] -pub enum ListenerMode { - TcpOnly, - UtpOnly, - TcpAndUtp, -} - -impl ListenerMode { - pub fn tcp_enabled(&self) -> bool { - match self { - ListenerMode::TcpOnly => true, - ListenerMode::UtpOnly => false, - ListenerMode::TcpAndUtp => true, - } - } - - pub fn utp_enabled(&self) -> bool { - match self { - ListenerMode::TcpOnly => false, - ListenerMode::UtpOnly => true, - ListenerMode::TcpAndUtp => true, - } - } -} - -#[derive(Debug, Clone)] -pub struct ListenerOptions { - pub mode: ListenerMode, - pub listen_addr: SocketAddr, - pub enable_upnp_port_forwarding: bool, - pub utp_opts: Option, - pub announce_port: Option, - pub ipv4_only: bool, -} - -impl Default for ListenerOptions { - fn default() -> Self { - Self { - // TODO: once uTP is stable upgrade default to both - mode: ListenerMode::TcpOnly, - listen_addr: (Ipv6Addr::UNSPECIFIED, 0).into(), - enable_upnp_port_forwarding: false, - utp_opts: None, - announce_port: None, - ipv4_only: false, - } - } -} - -impl ListenerOptions { - pub(crate) async fn start( - mut self, - parent_span: Option, - cancellation_token: CancellationToken, - bind_device: Option<&BindDevice>, - ) -> anyhow::Result { - let mut utp_opts = self.utp_opts.take().unwrap_or_default(); - utp_opts.cancellation_token = cancellation_token.clone(); - utp_opts.parent_span = parent_span; - utp_opts.dont_wait_for_lastack = true; - - let mut listen_addr = if self.ipv4_only { - if self.listen_addr.is_ipv6() && self.listen_addr.ip().is_unspecified() { - // Force to IPv4 unspecified if IPv6 unspecified was requested but we are v4 only - SocketAddr::from(([0, 0, 0, 0], self.listen_addr.port())) - } else { - self.listen_addr - } - } else { - self.listen_addr - }; - - let tcp_socket = if self.mode.tcp_enabled() { - let listener = TcpListener::bind_tcp( - listen_addr, - BindOpts { - request_dualstack: !self.ipv4_only, - reuseport: false, - device: bind_device, - }, - ) - .context("error starting TCP listener")?; - listen_addr = listener.bind_addr(); - info!( - "Listening on TCP {:?} for incoming peer connections", - listen_addr - ); - Some(listener) - } else { - None - }; - - let utp_socket = if self.mode.utp_enabled() { - let bind_result = UtpSocketUdp::new_udp_with_opts( - listen_addr, - utp_opts, - UtpSocketUdpOpts { bind_device }, - ) - .await; - match bind_result { - Ok(sock) => { - listen_addr = sock.bind_addr(); - info!( - "Listening on UDP {:?} for incoming uTP peer connections", - listen_addr - ); - Some(sock) - } - Err(e) if tcp_socket.is_some() => { - // If we listen over TCP, it's not a fatal error if we can't listen over uTP. - tracing::error!("Error listening on UDP {listen_addr:?}: {e:#}"); - None - } - Err(e) => { - return Err(e.into()); - } - } - } else { - None - }; - - let announce_port = if let Some(p) = self.announce_port { - Some(p) - } else if listen_addr.ip().is_loopback() { - None - } else { - Some(listen_addr.port()) - }; - Ok(ListenResult { - tcp_socket, - utp_socket, - announce_port, - addr: listen_addr, - enable_upnp_port_forwarding: self.enable_upnp_port_forwarding, - }) - } -} - -pub(crate) trait Accept { - const KIND: ConnectionKind; - - async fn accept( - &self, - ) -> anyhow::Result<( - SocketAddr, - ( - impl AsyncReadVectored + Send + 'static, - impl AsyncWrite + Unpin + Send + 'static, - ), - )>; -} - -impl Accept for TcpListener { - const KIND: ConnectionKind = ConnectionKind::Tcp; - async fn accept( - &self, - ) -> anyhow::Result<( - SocketAddr, - ( - impl AsyncReadVectored + Send + 'static, - impl AsyncWrite + Send + 'static, - ), - )> { - let (stream, addr) = self.accept().await.context("error accepting TCP")?; - let (read, write) = stream.into_split(); - Ok((addr, (read, write))) - } -} - -impl Accept for Arc { - const KIND: ConnectionKind = ConnectionKind::Utp; - async fn accept( - &self, - ) -> anyhow::Result<( - SocketAddr, - ( - impl AsyncReadVectored + Send + 'static, - impl AsyncWrite + Unpin + Send + 'static, - ), - )> { - let stream = self.accept().await.context("error accepting uTP")?; - let addr = stream.remote_addr(); - let (read, write) = stream.split(); - Ok((addr, (read, write))) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/merge_streams.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/merge_streams.rs deleted file mode 100644 index d2ada2c1..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/merge_streams.rs +++ /dev/null @@ -1,13 +0,0 @@ -use futures::stream::Stream; - -pub fn merge_streams< - I, - S1: Stream + 'static + Unpin, - S2: Stream + 'static + Unpin, ->( - s1: S1, - s2: S2, -) -> impl Stream + Unpin + 'static { - use tokio_stream::StreamExt; - s1.merge(s2) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/peer_connection.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/peer_connection.rs deleted file mode 100644 index 59c9eb03..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/peer_connection.rs +++ /dev/null @@ -1,644 +0,0 @@ -use std::{ - net::SocketAddr, - sync::Arc, - time::{Duration, Instant}, -}; - -use crate::{Error, Result, session::CheckedIncomingConnection, stream_connect::ConnectionKind}; -use buffers::{ByteBuf, ByteBufOwned}; -use futures::TryFutureExt; -use librtbit_core::{ - hash_id::Id20, - lengths::{ChunkInfo, ValidPieceIndex}, - peer_id::try_decode_peer_id, -}; -use parking_lot::RwLock; -use peer_binary_protocol::{ - Handshake, MAX_MSG_LEN, Message, - extended::{ - ExtendedMessage, PeerExtendedMessageIds, handshake::ExtendedHandshake, - ut_metadata::UtMetadata, ut_pex::UtPex, - }, - serialize_piece_preamble, -}; -use serde::{Deserialize, Serialize}; -use serde_with::serde_as; -use tokio::time::timeout; -use tracing::{Instrument, debug, trace, trace_span}; - -use crate::{ - read_buf::ReadBuf, - spawn_utils::BlockingSpawner, - stream_connect::StreamConnector, - type_aliases::{BoxAsyncReadVectored, BoxAsyncWrite}, -}; - -pub trait PeerConnectionHandler { - fn on_connected(&self, _connection_time: Duration) {} - fn should_send_bitfield(&self) -> bool; - fn serialize_bitfield_message_to_buf(&self, buf: &mut [u8]) -> anyhow::Result; - fn on_handshake(&self, handshake: Handshake, ckind: ConnectionKind) -> anyhow::Result<()>; - fn on_extended_handshake( - &self, - extended_handshake: &ExtendedHandshake, - ) -> anyhow::Result<()>; - async fn on_received_message(&self, msg: Message<'_>) -> anyhow::Result<()>; - fn should_transmit_have(&self, id: ValidPieceIndex) -> bool; - fn on_uploaded_bytes(&self, bytes: u32); - fn read_chunk(&self, chunk: &ChunkInfo, buf: &mut [u8]) -> anyhow::Result<()>; - fn update_my_extended_handshake( - &self, - _handshake: &mut ExtendedHandshake, - ) -> anyhow::Result<()> { - Ok(()) - } -} - -#[derive(Debug)] -pub enum WriterRequest { - Message(Message<'static>), - UtMetadata(UtMetadata), - UtPex(UtPex), - UtHolepunch(peer_binary_protocol::extended::ut_holepunch::HolepunchMessage), - ReadChunkRequest(ChunkInfo), - Disconnect(anyhow::Result<()>), -} - -#[serde_as] -#[derive(Default, Debug, Copy, Clone, Serialize, Deserialize, Eq, PartialEq)] -pub struct PeerConnectionOptions { - #[serde_as(as = "Option")] - pub connect_timeout: Option, - - #[serde_as(as = "Option")] - pub read_write_timeout: Option, - - #[serde_as(as = "Option")] - pub keep_alive_interval: Option, -} - -pub(crate) struct PeerConnection { - handler: H, - addr: SocketAddr, - info_hash: Id20, - peer_id: Id20, - options: PeerConnectionOptions, - spawner: BlockingSpawner, - connector: Arc, -} - -#[cfg(not(feature = "miri"))] -pub(crate) async fn with_timeout( - name: &'static str, - timeout_value: Duration, - fut: impl std::future::Future>, -) -> crate::Result { - match timeout(timeout_value, fut).await { - Ok(v) => v, - Err(_) => Err(Error::Timeout(name)), - } -} - -#[cfg(feature = "miri")] -pub(crate) async fn with_timeout( - _name: &'static str, - _timeout_value: Duration, - fut: impl std::future::Future>, -) -> crate::Result { - fut.await -} - -struct ManagePeerArgs { - handshake_supports_extended: bool, - read_buf: ReadBuf, - write_buf: Box<[u8; MAX_MSG_LEN]>, - read: BoxAsyncReadVectored, - write: BoxAsyncWrite, - outgoing_chan: tokio::sync::mpsc::UnboundedReceiver, - have_broadcast: tokio::sync::broadcast::Receiver, -} - -impl PeerConnection { - pub fn new( - addr: SocketAddr, - info_hash: Id20, - peer_id: Id20, - handler: H, - options: Option, - spawner: BlockingSpawner, - connector: Arc, - ) -> Self { - PeerConnection { - handler, - addr, - info_hash, - peer_id, - spawner, - options: options.unwrap_or_default(), - connector, - } - } - - // By the time this is called: - // read_buf should start with valuable data. The handshake should be removed from it. - pub async fn manage_peer_incoming( - &self, - outgoing_chan: tokio::sync::mpsc::UnboundedReceiver, - mut incoming: CheckedIncomingConnection, - have_broadcast: tokio::sync::broadcast::Receiver, - ) -> Result<()> { - use tokio::io::AsyncWriteExt; - - let rwtimeout = self - .options - .read_write_timeout - .unwrap_or_else(|| Duration::from_secs(10)); - - if incoming.handshake.info_hash != self.info_hash { - return Err(Error::WrongInfoHash); - } - - if incoming.handshake.peer_id == self.peer_id { - return Err(Error::ConnectingToOurselves); - } - - trace!( - "incoming connection: id={:?}", - try_decode_peer_id(incoming.handshake.peer_id) - ); - - let mut write_buf = Box::new([0u8; MAX_MSG_LEN]); - let handshake = Handshake::new(self.info_hash, self.peer_id); - let hlen = handshake.serialize_unchecked_len(&mut *write_buf); - with_timeout( - "writing handshake", - rwtimeout, - incoming - .writer - .write_all(&write_buf[..hlen]) - .map_err(Error::WriteHandshake), - ) - .await?; - - let handshake_supports_extended = handshake.supports_extended(); - - self.handler - .on_handshake(handshake, incoming.kind) - .map_err(Error::Anyhow)?; - - self.manage_peer(ManagePeerArgs { - handshake_supports_extended, - read_buf: incoming.read_buf, - write_buf, - read: incoming.reader, - write: incoming.writer, - outgoing_chan, - have_broadcast, - }) - .await - } - - pub async fn manage_peer_outgoing( - &self, - outgoing_chan: tokio::sync::mpsc::UnboundedReceiver, - have_broadcast: tokio::sync::broadcast::Receiver, - ) -> Result<()> { - use tokio::io::AsyncWriteExt; - let rwtimeout = self - .options - .read_write_timeout - .unwrap_or_else(|| Duration::from_secs(10)); - - let connect_timeout = self - .options - .connect_timeout - .unwrap_or_else(|| Duration::from_secs(10)); - - let now = Instant::now(); - let (ckind, mut read, mut write) = with_timeout( - "connecting", - connect_timeout, - self.connector.connect(self.addr), - ) - .await?; - - async move { - self.handler.on_connected(now.elapsed()); - - let mut write_buf = Box::new([0u8; MAX_MSG_LEN]); - let handshake = Handshake::new(self.info_hash, self.peer_id); - let hsz = handshake.serialize_unchecked_len(&mut *write_buf); - with_timeout( - "writing", - rwtimeout, - write - .write_all(&write_buf[..hsz]) - .map_err(Error::WriteHandshake), - ) - .await?; - - let mut read_buf = ReadBuf::new(); - let h = read_buf.read_handshake(&mut read, rwtimeout).await?; - let handshake_supports_extended = h.supports_extended(); - trace!( - peer_id=?h.peer_id, - decoded_id=?try_decode_peer_id(h.peer_id), - "connected", - ); - if h.info_hash != self.info_hash { - return Err(Error::WrongInfoHash); - } - - if h.peer_id == self.peer_id { - return Err(Error::ConnectingToOurselves); - } - - self.handler.on_handshake(h, ckind).map_err(Error::Anyhow)?; - - self.manage_peer(ManagePeerArgs { - handshake_supports_extended, - read_buf, - write_buf, - read, - write, - outgoing_chan, - have_broadcast, - }) - .await - } - .instrument(trace_span!("", kind=%ckind)) - .await - } - - async fn manage_peer(&self, args: ManagePeerArgs) -> Result<()> { - let ManagePeerArgs { - handshake_supports_extended, - mut read_buf, - mut write_buf, - mut read, - mut write, - mut outgoing_chan, - mut have_broadcast, - } = args; - - use tokio::io::AsyncWriteExt; - - let rwtimeout = self - .options - .read_write_timeout - .unwrap_or_else(|| Duration::from_secs(10)); - - let extended_handshake: RwLock> = RwLock::new(None); - let extended_handshake_ref = &extended_handshake; - let supports_extended = handshake_supports_extended; - - if supports_extended { - let mut my_extended = ExtendedHandshake::new(); - my_extended.v = Some(ByteBuf(crate::client_name_and_version().as_bytes())); - my_extended.yourip = Some(self.addr.ip().into()); - self.handler - .update_my_extended_handshake(&mut my_extended) - .map_err(Error::Anyhow)?; - let my_extended = Message::Extended(ExtendedMessage::Handshake(my_extended)); - trace!("sending extended handshake: {:?}", &my_extended); - let esz = my_extended.serialize(&mut *write_buf, &Default::default)?; - with_timeout( - "writing extended handshake", - rwtimeout, - write.write_all(&write_buf[..esz]).map_err(Error::Write), - ) - .await?; - } - - let writer = async move { - let keep_alive_interval = self - .options - .keep_alive_interval - .unwrap_or_else(|| Duration::from_secs(120)); - - if self.handler.should_send_bitfield() { - let len = self - .handler - .serialize_bitfield_message_to_buf(&mut *write_buf) - .map_err(Error::Anyhow)?; - with_timeout( - "writing bitfield", - rwtimeout, - write.write_all(&write_buf[..len]).map_err(Error::Write), - ) - .await?; - trace!("sent bitfield"); - } - - let len = Message::Unchoke.serialize(&mut *write_buf, &Default::default)?; - with_timeout( - "writing", - rwtimeout, - write.write_all(&write_buf[..len]).map_err(Error::Write), - ) - .await?; - trace!("sent unchoke"); - - let mut broadcast_closed = false; - - loop { - let req = loop { - break tokio::select! { - r = have_broadcast.recv(), if !broadcast_closed => match r { - Ok(id) => { - if self.handler.should_transmit_have(id) { - WriterRequest::Message(Message::Have(id.get())) - } else { - continue - } - }, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - broadcast_closed = true; - debug!("broadcast channel closed, will not poll it anymore"); - continue - }, - _ => continue - }, - r = timeout(keep_alive_interval, outgoing_chan.recv()) => match r { - Ok(Some(msg)) => msg, - Ok(None) => { - return Err(Error::TorrentIsNotLive); - } - Err(_) => WriterRequest::Message(Message::KeepAlive), - } - }; - }; - - tokio::task::yield_now().await; - - let mut uploaded_add = None; - - trace!("about to send: {:?}", &req); - let ext_msg_ids = &|| { - extended_handshake_ref - .read() - .as_ref() - .map(|e| *e) - .unwrap_or_default() - }; - - let len = match req { - WriterRequest::Message(msg) => msg.serialize(&mut *write_buf, ext_msg_ids)?, - WriterRequest::UtMetadata(utm) => { - Message::Extended(ExtendedMessage::UtMetadata(utm.as_borrowed())) - .serialize(&mut *write_buf, ext_msg_ids)? - } - WriterRequest::UtPex(ut_pex) => { - Message::Extended(ExtendedMessage::UtPex(ut_pex.as_borrowed())) - .serialize(&mut *write_buf, ext_msg_ids)? - } - WriterRequest::UtHolepunch(hp) => { - Message::Extended(ExtendedMessage::UtHolepunch(hp)) - .serialize(&mut *write_buf, ext_msg_ids)? - } - WriterRequest::ReadChunkRequest(chunk) => { - #[allow(unused_mut)] - let mut skip_reading_for_e2e_tests = false; - - #[cfg(test)] - { - use tracing::warn; - // This is poor-mans fault injection for running e2e tests. - use crate::tests::test_util::TestPeerMetadata; - let tpm = TestPeerMetadata::from_peer_id(self.peer_id); - use rand::Rng; - if rand::rng().random_bool(tpm.disconnect_probability()) { - return Err(Error::TestDisconnect); - } - - #[allow(clippy::cast_possible_truncation)] - let sleep_ms = (rand::rng().random::() - * (tpm.max_random_sleep_ms as f64)) - as u64; - if sleep_ms > 0 { - tokio::time::sleep(Duration::from_millis(sleep_ms)).await; - } - - if rand::rng().random_bool(tpm.bad_data_probability()) { - warn!( - "will NOT actually read the data to simulate a malicious peer that sends garbage" - ); - write_buf.fill(0); - skip_reading_for_e2e_tests = true; - } - } - - // this whole section is an optimization - let preamble_len = serialize_piece_preamble(&chunk, &mut *write_buf); - let full_len = preamble_len + chunk.size as usize; - if !skip_reading_for_e2e_tests { - self.spawner - .block_in_place_with_semaphore(|| { - self.handler - .read_chunk(&chunk, &mut write_buf[preamble_len..]) - }) - .await - .map_err(Error::ReadChunk)?; - } - - uploaded_add = Some(chunk.size); - full_len - } - WriterRequest::Disconnect(res) => { - trace!("disconnect requested, closing writer"); - match res { - Ok(()) => return Err(Error::Disconnect), - Err(e) => return Err(Error::DisconnectWithSource(e)), - } - } - }; - - with_timeout( - "writing", - rwtimeout, - write.write_all(&write_buf[..len]).map_err(Error::Write), - ) - .await?; - - if let Some(uploaded_add) = uploaded_add { - self.handler.on_uploaded_bytes(uploaded_add) - } - } - - // For type inference. - #[allow(unreachable_code)] - Ok::<_, Error>(()) - }; - - let reader = async move { - loop { - let message = read_buf.read_message(&mut read, rwtimeout).await?; - trace!("received: {:?}", &message); - - tokio::task::yield_now().await; - - if let Message::Extended(ExtendedMessage::Handshake(h)) = &message { - *extended_handshake_ref.write() = Some(h.peer_extended_messages()); - self.handler - .on_extended_handshake(h) - .map_err(Error::Anyhow)?; - } else { - self.handler - .on_received_message(message) - .await - .map_err(Error::Anyhow)?; - } - } - - // For type inference. - #[allow(unreachable_code)] - Ok::<_, Error>(()) - }; - - tokio::select! { - r = reader => { - if let Err(e) = r.as_ref() { - trace!("reader finished with error: {e:#}"); - } else { - trace!("reader finished without error"); - } - r - } - r = writer => { - if let Err(e) = r.as_ref() { - trace!("writer finished with error: {e:#}"); - } else { - trace!("writer finished without error"); - } - r - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use librtbit_core::hash_id::Id20; - use peer_binary_protocol::{Handshake, MAX_MSG_LEN, Message, MessageDeserializeError}; - - #[test] - fn test_peer_connection_handshake_encoding() { - let info_hash = Id20::new([0xAA; 20]); - let peer_id = Id20::new([0xBB; 20]); - let handshake = Handshake::new(info_hash, peer_id); - - let mut buf = [0u8; MAX_MSG_LEN]; - let len = handshake.serialize_unchecked_len(&mut buf); - - // BitTorrent handshake is 68 bytes: - // 1 (pstr_len) + 19 (pstr) + 8 (reserved) + 20 (info_hash) + 20 (peer_id) - assert_eq!(len, 68); - - // First byte should be 19 (pstr length). - assert_eq!(buf[0], 19); - - // Bytes 1..20 should be "BitTorrent protocol". - assert_eq!(&buf[1..20], b"BitTorrent protocol"); - - // Info hash at bytes 28..48. - assert_eq!(&buf[28..48], &[0xAA; 20]); - - // Peer ID at bytes 48..68. - assert_eq!(&buf[48..68], &[0xBB; 20]); - } - - #[test] - fn test_peer_connection_handshake_roundtrip() { - let info_hash = Id20::new([0x11; 20]); - let peer_id = Id20::new([0x22; 20]); - let original = Handshake::new(info_hash, peer_id); - - let mut buf = [0u8; MAX_MSG_LEN]; - let len = original.serialize_unchecked_len(&mut buf); - - let (deserialized, consumed) = Handshake::deserialize(&buf[..len]).unwrap(); - assert_eq!(consumed, 68); - assert_eq!(deserialized.info_hash, info_hash); - assert_eq!(deserialized.peer_id, peer_id); - assert!(deserialized.supports_extended()); - } - - #[test] - fn test_peer_connection_handshake_supports_extended() { - let info_hash = Id20::new([0; 20]); - let peer_id = Id20::new([0; 20]); - let handshake = Handshake::new(info_hash, peer_id); - - // Handshake::new sets the extended messaging bit. - assert!(handshake.supports_extended()); - } - - #[test] - fn test_peer_connection_handshake_deserialize_too_short() { - let short_buf = [0u8; 10]; // Way too short for a handshake. - let result = Handshake::deserialize(&short_buf); - assert!(result.is_err()); - match result { - Err(MessageDeserializeError::NotEnoughData(_, _)) => {} - Err(e) => panic!("Expected NotEnoughData error, got {e:?}"), - Ok(_) => panic!("Expected error"), - } - } - - #[test] - fn test_peer_connection_message_framing() { - // Test that a KeepAlive message serializes to 4 zero bytes (length=0). - let msg = Message::KeepAlive; - let mut buf = [0u8; MAX_MSG_LEN]; - let len = msg.serialize(&mut buf, &Default::default).unwrap(); - // KeepAlive: 4-byte length prefix of 0. - assert_eq!(len, 4); - assert_eq!(&buf[..4], &[0, 0, 0, 0]); - } - - #[test] - fn test_peer_connection_unchoke_message() { - let msg = Message::Unchoke; - let mut buf = [0u8; MAX_MSG_LEN]; - let len = msg.serialize(&mut buf, &Default::default).unwrap(); - // Unchoke: 4-byte length (=1) + 1-byte msg_id (=1) = 5 bytes total. - assert_eq!(len, 5); - // Length prefix = 1 - assert_eq!(&buf[..4], &[0, 0, 0, 1]); - // Message ID for Unchoke = 1 - assert_eq!(buf[4], 1); - } - - #[test] - fn test_peer_connection_interested_message() { - let msg = Message::Interested; - let mut buf = [0u8; MAX_MSG_LEN]; - let len = msg.serialize(&mut buf, &Default::default).unwrap(); - // Interested: 4-byte length (=1) + 1-byte msg_id (=2) = 5 bytes total. - assert_eq!(len, 5); - assert_eq!(&buf[..4], &[0, 0, 0, 1]); - assert_eq!(buf[4], 2); - } - - #[test] - fn test_peer_connection_options_default() { - let opts = PeerConnectionOptions::default(); - assert!(opts.connect_timeout.is_none()); - assert!(opts.read_write_timeout.is_none()); - assert!(opts.keep_alive_interval.is_none()); - } - - #[test] - fn test_peer_connection_options_serialization() { - let opts = PeerConnectionOptions { - connect_timeout: Some(Duration::from_secs(5)), - read_write_timeout: Some(Duration::from_secs(10)), - keep_alive_interval: Some(Duration::from_secs(120)), - }; - - let json = serde_json::to_string(&opts).unwrap(); - let deserialized: PeerConnectionOptions = serde_json::from_str(&json).unwrap(); - assert_eq!(opts, deserialized); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/peer_info_reader/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/peer_info_reader/mod.rs deleted file mode 100644 index 6439cdc9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/peer_info_reader/mod.rs +++ /dev/null @@ -1,257 +0,0 @@ -use std::{net::SocketAddr, sync::Arc}; - -use bencode::from_bytes; -use buffers::{ByteBuf, ByteBufOwned}; -use bytes::Bytes; -use librtbit_core::{ - constants::CHUNK_SIZE, - hash_id::Id20, - lengths::{ChunkInfo, last_element_size}, - torrent_metainfo::TorrentMetaV1Info, -}; -use parking_lot::{Mutex, RwLock}; -use peer_binary_protocol::{ - Handshake, Message, - extended::{ - ExtendedMessage, - handshake::ExtendedHandshake, - ut_metadata::{UtMetadata, UtMetadataData}, - }, -}; -use sha1w::{ISha1, Sha1}; -use tokio::sync::mpsc::UnboundedSender; -use tracing::trace; - -use crate::{ - peer_connection::{ - PeerConnection, PeerConnectionHandler, PeerConnectionOptions, WriterRequest, - }, - spawn_utils::BlockingSpawner, - stream_connect::{ConnectionKind, StreamConnector}, -}; - -pub(crate) async fn read_metainfo_from_peer( - addr: SocketAddr, - peer_id: Id20, - info_hash: Id20, - peer_connection_options: Option, - spawner: BlockingSpawner, - connector: Arc, -) -> anyhow::Result { - let (result_tx, result_rx) = tokio::sync::oneshot::channel::< - Result<(TorrentMetaV1Info, ByteBufOwned), bencode::DeserializeError>, - >(); - let (writer_tx, writer_rx) = tokio::sync::mpsc::unbounded_channel::(); - let handler = Handler { - addr, - info_hash, - writer_tx, - result_tx: Mutex::new(Some(result_tx)), - locked: RwLock::new(None), - }; - let connection = PeerConnection::new( - addr, - info_hash, - peer_id, - handler, - peer_connection_options, - spawner, - connector, - ); - - let result_reader = result_rx; - let (_, brx) = tokio::sync::broadcast::channel(1); - let connection_runner = async move { connection.manage_peer_outgoing(writer_rx, brx).await }; - - tokio::select! { - result = result_reader => Ok(result??), - whatever = connection_runner => match whatever { - Ok(_) => anyhow::bail!("connection runner completed first"), - Err(e) => Err(e.into()) - } - } -} - -#[derive(Default)] -struct HandlerLocked { - metadata_size: u32, - total_pieces: usize, - buffer: Vec, - received_pieces: Vec, -} - -impl HandlerLocked { - fn new(metadata_size: u32) -> anyhow::Result { - if metadata_size > 32 * 1024 * 1024 { - anyhow::bail!("metadata size {} is too big", metadata_size); - } - let buffer = vec![0u8; metadata_size as usize]; - let total_pieces: usize = (metadata_size as u64) - .div_ceil(CHUNK_SIZE as u64) - .try_into()?; - let received_pieces = vec![false; total_pieces]; - Ok(Self { - metadata_size, - received_pieces, - buffer, - total_pieces, - }) - } - fn piece_size(&self, index: u32) -> usize { - if index as usize == self.total_pieces - 1 { - last_element_size(self.metadata_size as u64, CHUNK_SIZE as u64) - .try_into() - .unwrap() - } else { - CHUNK_SIZE as usize - } - } - fn record_piece( - &mut self, - d: &UtMetadataData, - info_hash: &Id20, - ) -> anyhow::Result { - let piece = d.piece(); - if piece as usize >= self.total_pieces { - anyhow::bail!("wrong index"); - } - let offset = (piece * CHUNK_SIZE) as usize; - let size = self.piece_size(piece); - if d.len() != size { - anyhow::bail!( - "expected length of piece {} to be {}, but got {}", - piece, - size, - d.len() - ); - } - if self.received_pieces[piece as usize] { - anyhow::bail!("already received piece {}", piece); - } - d.copy_to_slice(&mut self.buffer[offset..offset + d.len()]); - self.received_pieces[piece as usize] = true; - - if self.received_pieces.iter().all(|p| *p) { - // check metadata - let mut hash = Sha1::new(); - hash.update(&self.buffer); - if hash.finish() != info_hash.0 { - anyhow::bail!("info checksum invalid"); - } - Ok(true) - } else { - Ok(false) - } - } -} - -pub type TorrentAndInfoBytes = (TorrentMetaV1Info, ByteBufOwned); - -struct Handler { - addr: SocketAddr, - info_hash: Id20, - writer_tx: UnboundedSender, - result_tx: Mutex< - Option< - tokio::sync::oneshot::Sender>, - >, - >, - locked: RwLock>, -} - -impl PeerConnectionHandler for Handler { - fn should_send_bitfield(&self) -> bool { - false - } - - fn serialize_bitfield_message_to_buf(&self, _buf: &mut [u8]) -> anyhow::Result { - Ok(0) - } - - fn on_handshake(&self, handshake: Handshake, _kind: ConnectionKind) -> anyhow::Result<()> { - if !handshake.supports_extended() { - anyhow::bail!( - "this peer does not support extended handshaking, which is a prerequisite to download metadata" - ) - } - Ok(()) - } - - async fn on_received_message(&self, msg: Message<'_>) -> anyhow::Result<()> { - trace!("{}: received message: {:?}", self.addr, msg); - - if let Message::Extended(ExtendedMessage::UtMetadata(UtMetadata::Data(utdata))) = msg { - let piece_ready = self - .locked - .write() - .as_mut() - .unwrap() - .record_piece(&utdata, &self.info_hash)?; - if piece_ready { - let buf = Bytes::from(self.locked.write().take().unwrap().buffer); - let info = from_bytes::>(&buf) - .map(|i| { - use clone_to_owned::CloneToOwned; - i.clone_to_owned(Some(&buf)) - }) - .map_err(|e| { - trace!("error deserializing TorrentMetaV1Info: {e:#}"); - e.into_kind() - }) - .map(|i| (i, ByteBufOwned(buf))); - - self.result_tx - .lock() - .take() - .ok_or_else(|| anyhow::anyhow!("oneshot is consumed"))? - .send(info) - .map_err(|_| { - anyhow::anyhow!("torrent info deserialized, but consumer closed") - })?; - } - } - Ok(()) - } - - fn on_uploaded_bytes(&self, _bytes: u32) {} - - fn read_chunk(&self, _chunk: &ChunkInfo, _buf: &mut [u8]) -> anyhow::Result<()> { - anyhow::bail!("the peer is not supposed to be requesting chunks") - } - - fn on_extended_handshake( - &self, - extended_handshake: &ExtendedHandshake, - ) -> anyhow::Result<()> { - let metadata_size = match extended_handshake.metadata_size { - Some(metadata_size) => metadata_size, - None => anyhow::bail!("peer does not have metadata_size"), - }; - - if extended_handshake.m.ut_metadata.is_none() { - anyhow::bail!("peer does not support ut_metadata"); - } - - self.writer_tx - .send(WriterRequest::Message(Message::Unchoke))?; - self.writer_tx - .send(WriterRequest::Message(Message::Interested))?; - - let inner = HandlerLocked::new(metadata_size)?; - let total_pieces = inner.total_pieces; - - self.locked.write().replace(inner); - - for i in 0..total_pieces { - self.writer_tx - .send(WriterRequest::Message(Message::Extended( - ExtendedMessage::UtMetadata(UtMetadata::Request(i.try_into()?)), - )))?; - } - Ok(()) - } - - fn should_transmit_have(&self, _id: librtbit_core::lengths::ValidPieceIndex) -> bool { - false - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/piece_tracker.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/piece_tracker.rs deleted file mode 100644 index 9ec0b0ae..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/piece_tracker.rs +++ /dev/null @@ -1,965 +0,0 @@ -//! Coordinates piece download state by wrapping ChunkTracker with inflight piece tracking. -//! -//! This module provides the [`PieceTracker`] type which encapsulates the relationship between -//! queued pieces (in ChunkTracker) and in-flight pieces (being downloaded by a peer). -//! -//! The key invariant maintained is: a piece is in exactly one state at any time: -//! - HAVE (completed) -//! - QUEUED (available to download) -//! - IN_FLIGHT (currently being downloaded) -//! - NOT_NEEDED (not selected for download) - -use std::{ - collections::{HashMap, HashSet}, - time::{Duration, Instant}, -}; - -use buffers::ByteBuf; -use librtbit_core::lengths::ValidPieceIndex; -use peer_binary_protocol::Piece; - -use crate::{ - chunk_tracker::{ChunkMarkingResult, ChunkTracker}, - file_info::FileInfo, - type_aliases::{FileInfos, FilePriorities, PeerHandle}, -}; - -/// Tracks a piece currently being downloaded. -#[derive(Debug, Clone)] -pub struct InflightPiece { - pub peer: PeerHandle, - pub started: Instant, -} - -/// Result of attempting to acquire a piece. -#[derive(Debug)] -pub enum AcquireResult { - /// A new piece was reserved from the queue. - Reserved(ValidPieceIndex), - /// A piece was stolen from a slower peer. - Stolen { - piece: ValidPieceIndex, - from_peer: PeerHandle, - }, - /// No pieces are available for this peer. - NoneAvailable, -} - -/// Parameters for acquiring a piece. -pub struct AcquireRequest<'a, I, P, S> -where - I: Iterator, - P: Fn(ValidPieceIndex) -> bool, - S: Fn(ValidPieceIndex) -> bool, -{ - /// The peer requesting a piece. - pub peer: PeerHandle, - /// The peer's average piece download time (for steal calculations). - pub peer_avg_time: Option, - /// Priority pieces to check first (e.g., for streaming). - pub priority_pieces: I, - /// File download priority ordering. - pub file_priorities: &'a FilePriorities, - /// File metadata for iterating pieces. - pub file_infos: &'a FileInfos, - /// Returns true if the peer has the given piece. - pub peer_has_piece: P, - /// Returns true if the piece can be stolen (e.g., not locked for writing). - pub can_steal: S, -} - -/// Coordinates piece download state. -/// -/// Wraps a [`ChunkTracker`] with tracking of which pieces are currently being downloaded -/// (in-flight) and by which peer. This ensures that: -/// -/// - A piece is only assigned to one peer at a time (unless stolen) -/// - Pieces are properly requeued when a peer dies -/// - State transitions maintain invariants -pub struct PieceTracker { - chunks: ChunkTracker, - inflight: HashMap, -} - -impl PieceTracker { - // === CONSTRUCTION === - - /// Create a new PieceTracker wrapping the given ChunkTracker. - pub fn new(chunks: ChunkTracker) -> Self { - Self { - chunks, - inflight: HashMap::new(), - } - } - - /// Read-only access to the underlying ChunkTracker. - pub fn chunks(&self) -> &ChunkTracker { - &self.chunks - } - - /// Consume the PieceTracker, requeuing any in-flight pieces. - /// - /// This is used when pausing a torrent - any pieces that were being downloaded - /// need to be put back in the queue so they can be re-downloaded on resume. - pub fn into_chunks(mut self) -> ChunkTracker { - // Requeue all in-flight pieces so they'll be re-downloaded on resume - for piece in self.inflight.into_keys() { - self.chunks.mark_piece_broken_if_not_have(piece); - } - self.chunks - } - - // === PIECE ACQUISITION === - - /// Attempt to acquire a piece for the requesting peer. - /// - /// The acquisition strategy is: - /// 1. Try to steal a piece from a peer that's 10x slower - /// 2. Try to reserve a piece from the queue (priority pieces first) - /// 3. Try to steal a piece from a peer that's 3x slower - /// - /// If `Stolen` is returned, the caller MUST call `peers.on_steal()` to notify - /// the old peer and update counters. - pub fn acquire_piece(&mut self, mut req: AcquireRequest) -> AcquireResult - where - I: Iterator, - P: Fn(ValidPieceIndex) -> bool, - S: Fn(ValidPieceIndex) -> bool, - { - // 1. Try steal with 10x threshold (very slow peer) - if let Some(result) = self.try_steal(&req, 10.0) { - return result; - } - - // 2. Try reserve from priority_pieces then queued pieces - // First check priority pieces that aren't already downloaded or in-flight - for piece in &mut req.priority_pieces { - if !self.chunks.is_piece_have(piece) - && !self.inflight.contains_key(&piece) - && (req.peer_has_piece)(piece) - { - return self.reserve_piece(piece, req.peer); - } - } - - // Then check naturally ordered queued pieces - // Note: iter_queued_pieces only returns pieces in queue_pieces (not in-flight) - let queued: Vec<_> = self - .chunks - .iter_queued_pieces(req.file_priorities, req.file_infos) - .collect(); - - for piece in queued { - if (req.peer_has_piece)(piece) { - return self.reserve_piece(piece, req.peer); - } - } - - // 3. Try steal with 3x threshold (moderately slow peer) - if let Some(result) = self.try_steal(&req, 3.0) { - return result; - } - - AcquireResult::NoneAvailable - } - - /// Reserve a piece: remove from queue, add to inflight. - fn reserve_piece(&mut self, piece: ValidPieceIndex, peer: PeerHandle) -> AcquireResult { - self.chunks.reserve_needed_piece(piece); - self.inflight.insert( - piece, - InflightPiece { - peer, - started: Instant::now(), - }, - ); - AcquireResult::Reserved(piece) - } - - /// Try to steal a piece from a slower peer. - fn try_steal( - &mut self, - req: &AcquireRequest, - threshold: f64, - ) -> Option - where - I: Iterator, - P: Fn(ValidPieceIndex) -> bool, - S: Fn(ValidPieceIndex) -> bool, - { - let my_avg = req.peer_avg_time?; - let min_elapsed = Duration::from_secs_f64(my_avg.as_secs_f64() * threshold); - - // Find the slowest piece from another peer that exceeds threshold - // and that the stealing peer actually has (can download) - let (piece, old_peer, _) = self - .inflight - .iter() - .filter(|(_, info)| info.peer != req.peer) - .filter(|(p, _)| (req.peer_has_piece)(**p)) - .map(|(p, info)| (*p, info.peer, info.started.elapsed())) - .filter(|(_, _, elapsed)| *elapsed >= min_elapsed) - .max_by_key(|(_, _, elapsed)| *elapsed)?; - - // Check can_steal (e.g., per_piece_lock) - if !(req.can_steal)(piece) { - return None; - } - - // Update ownership (piece stays in inflight, just changes owner) - let info = self.inflight.get_mut(&piece)?; - info.peer = req.peer; - info.started = Instant::now(); - - Some(AcquireResult::Stolen { - piece, - from_peer: old_peer, - }) - } - - // === PIECE COMPLETION === - - /// Remove piece from inflight tracking (e.g., after all chunks received). - /// - /// Returns download duration if piece was in-flight. - /// Note: Does NOT mark the piece as downloaded - caller should do hash check - /// and then call `mark_piece_hash_ok` or `mark_piece_hash_failed`. - pub fn take_inflight(&mut self, piece: ValidPieceIndex) -> Option { - let inflight = self.inflight.remove(&piece)?; - Some(inflight.started.elapsed()) - } - - /// Mark piece as downloaded after successful hash verification. - pub fn mark_piece_hash_ok(&mut self, piece: ValidPieceIndex) { - self.chunks.mark_piece_downloaded(piece); - } - - /// Mark piece as failed after hash verification failure - requeues the piece. - pub fn mark_piece_hash_failed(&mut self, piece: ValidPieceIndex) { - self.chunks.mark_piece_broken_if_not_have(piece); - } - - /// Release all pieces owned by a peer (on peer death). - /// - /// Moves all pieces owned by the peer from IN_FLIGHT back to QUEUED. - /// Returns the number of pieces released. - pub fn release_pieces_owned_by(&mut self, peer: PeerHandle) -> usize { - // Collect pieces to release (can't modify while iterating) - let pieces_to_release: Vec<_> = self - .inflight - .iter() - .filter(|(_, info)| info.peer == peer) - .map(|(p, _)| *p) - .collect(); - - let count = pieces_to_release.len(); - for piece in pieces_to_release { - self.inflight.remove(&piece); - self.chunks.mark_piece_broken_if_not_have(piece); - } - count - } - - // === QUERIES === - - /// Get the inflight info for a piece, if it's currently being downloaded. - pub fn get_inflight(&self, piece: ValidPieceIndex) -> Option<&InflightPiece> { - self.inflight.get(&piece) - } - - /// Check if a piece is currently in-flight. - #[allow(dead_code)] - pub fn is_inflight(&self, piece: ValidPieceIndex) -> bool { - self.inflight.contains_key(&piece) - } - - /// Get the number of pieces currently in-flight. - #[allow(dead_code)] - pub fn inflight_count(&self) -> usize { - self.inflight.len() - } - - // === PASS-THROUGH METHODS === - - /// Mark a chunk as downloaded. Returns the result indicating if the piece is complete. - pub fn mark_chunk_downloaded( - &mut self, - piece: &Piece>, - ) -> Option { - self.chunks.mark_chunk_downloaded(piece) - } - - /// Update which files are selected for download. - pub fn update_only_files( - &mut self, - file_infos: &FileInfos, - new_only_files: &HashSet, - ) -> anyhow::Result { - self.chunks.update_only_files(file_infos, new_only_files) - } - - /// Update per-file have bytes when a piece completes. Returns remaining bytes for the file. - pub fn update_file_have_on_piece_completed( - &mut self, - piece_id: ValidPieceIndex, - file_id: usize, - file_info: &FileInfo, - ) -> u64 { - self.chunks - .update_file_have_on_piece_completed(piece_id, file_id, file_info) - } - - /// Flush the have pieces bitfield to disk. - pub fn flush_have_pieces(&mut self, flush_async: bool) -> anyhow::Result<()> { - self.chunks.get_have_pieces_mut().flush(flush_async) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{bitv::BitV as BitVTrait, type_aliases::BF}; - use librtbit_core::lengths::Lengths; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - - fn peer(id: u8) -> PeerHandle { - SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, id)), 6881) - } - - /// Create a simple ChunkTracker for testing. - /// Creates a torrent with the specified number of pieces, all selected. - fn make_test_chunk_tracker(num_pieces: u32) -> ChunkTracker { - // Create a simple single-file torrent - let piece_length = 16384u32; // 16KB pieces - let total_length = piece_length as u64 * num_pieces as u64; - - let lengths = Lengths::new(total_length, piece_length).unwrap(); - - let bf_len = lengths.piece_bitfield_bytes(); - - // No pieces downloaded yet (empty have) - let have = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - - // All pieces selected - let mut selected = BF::from_boxed_slice(vec![0u8; bf_len].into_boxed_slice()); - for i in 0..num_pieces as usize { - selected.set(i, true); - } - - // Single file spanning all pieces - let file_infos = vec![crate::file_info::FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - len: total_length, - piece_range: 0..num_pieces, - attrs: Default::default(), - }]; - - ChunkTracker::new(have.into_dyn(), selected, lengths, &file_infos).unwrap() - } - - fn make_test_file_infos(num_pieces: u32) -> FileInfos { - let piece_length = 16384u64; - vec![crate::file_info::FileInfo { - relative_filename: "test.dat".into(), - offset_in_torrent: 0, - len: piece_length * num_pieces as u64, - piece_range: 0..num_pieces, - attrs: Default::default(), - }] - } - - fn make_default_file_priorities(file_infos: &FileInfos) -> FilePriorities { - (0..file_infos.len()).collect() - } - - #[test] - fn test_new_piece_tracker() { - let chunks = make_test_chunk_tracker(10); - let tracker = PieceTracker::new(chunks); - assert_eq!(tracker.inflight_count(), 0); - } - - #[test] - fn test_reserve_piece_from_queue() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, // Peer has all pieces - can_steal: |_| true, - }); - - // Should reserve piece 0 (first in queue) - match result { - AcquireResult::Reserved(piece) => { - assert_eq!(piece.get(), 0); - assert!(tracker.is_inflight(piece)); - assert_eq!(tracker.inflight_count(), 1); - } - _ => panic!("Expected Reserved, got {:?}", result), - } - } - - #[test] - fn test_reserve_filters_by_peer_has_piece() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - // Peer only has piece 2 and later - // Note: iter_queued_pieces iterates in order: first, last, middle - // So for 0..5: 0, 4, 1, 2, 3 - // With filter >= 2, we get 4 first (skips 0, takes 4) - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |p| p.get() >= 2, - can_steal: |_| true, - }); - - match result { - AcquireResult::Reserved(piece) => { - // Got piece 4 (first one peer has in iteration order) - assert!(piece.get() >= 2, "Should have gotten a piece >= 2"); - } - _ => panic!("Expected Reserved, got {:?}", result), - } - } - - #[test] - fn test_complete_piece() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - // Reserve a piece first - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - let piece = match result { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - - // Complete the piece (take_inflight + hash check + mark_piece_hash_ok) - let duration = tracker.take_inflight(piece); - assert!(duration.is_some()); - assert!(!tracker.is_inflight(piece)); - // Simulate successful hash check - tracker.mark_piece_hash_ok(piece); - assert!(tracker.chunks().is_piece_have(piece)); - } - - #[test] - fn test_fail_piece_requeues() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - // Reserve piece 0 - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - let piece = match result { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - - assert!(tracker.is_inflight(piece)); - - // Fail the piece (take_inflight + hash check fails + mark_piece_hash_failed) - let duration = tracker.take_inflight(piece); - assert!(duration.is_some()); - // Simulate failed hash check - tracker.mark_piece_hash_failed(piece); - - // Should no longer be in-flight - assert!(!tracker.is_inflight(piece)); - // Should not be in have - assert!(!tracker.chunks().is_piece_have(piece)); - // Should be back in queue - verify by trying to reserve it again - let result2 = tracker.acquire_piece(AcquireRequest { - peer: peer(2), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |p| p == piece, // Only has the failed piece - can_steal: |_| true, - }); - - match result2 { - AcquireResult::Reserved(p) => assert_eq!(p, piece), - _ => panic!("Expected piece to be re-reservable after fail"), - } - } - - #[test] - fn test_release_pieces_owned_by_peer() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - let peer_a = peer(1); - let peer_b = peer(2); - - // Peer A reserves first two pieces (order: 0, 4, 1, 2, 3) - // So peer A gets pieces 0 and 4 - let piece_a1 = match tracker.acquire_piece(AcquireRequest { - peer: peer_a, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - let piece_a2 = match tracker.acquire_piece(AcquireRequest { - peer: peer_a, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - - // Peer B reserves next piece - let piece_b = match tracker.acquire_piece(AcquireRequest { - peer: peer_b, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - - assert_eq!(tracker.inflight_count(), 3); - assert!(tracker.is_inflight(piece_a1)); - assert!(tracker.is_inflight(piece_a2)); - assert!(tracker.is_inflight(piece_b)); - - // Peer A dies - let released = tracker.release_pieces_owned_by(peer_a); - assert_eq!(released, 2); - assert_eq!(tracker.inflight_count(), 1); // Only peer B's piece remains - - // Verify peer B's piece is still in-flight - assert!(tracker.is_inflight(piece_b)); - // Verify peer A's pieces are no longer in-flight - assert!(!tracker.is_inflight(piece_a1)); - assert!(!tracker.is_inflight(piece_a2)); - } - - #[test] - fn test_into_chunks_requeues_inflight() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - // Reserve pieces 0 and 1 - tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - assert_eq!(tracker.inflight_count(), 2); - - // Convert back to chunks (simulates pause) - let chunks = tracker.into_chunks(); - - // Create a new tracker and verify pieces are back in queue - let mut new_tracker = PieceTracker::new(chunks); - let result = new_tracker.acquire_piece(AcquireRequest { - peer: peer(2), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - // Should get piece 0 again (was requeued) - match result { - AcquireResult::Reserved(p) => assert_eq!(p.get(), 0), - _ => panic!("Expected to reserve piece 0 after into_chunks"), - } - } - - #[test] - fn test_priority_pieces_checked_first() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - // Priority pieces: piece 3, then piece 2 - let piece3 = tracker - .chunks() - .get_lengths() - .validate_piece_index(3) - .unwrap(); - let piece2 = tracker - .chunks() - .get_lengths() - .validate_piece_index(2) - .unwrap(); - let priority = vec![piece3, piece2]; - - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: priority.into_iter(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - // Should get piece 3 (first priority piece) - match result { - AcquireResult::Reserved(p) => assert_eq!(p.get(), 3), - _ => panic!("Expected Reserved(3), got {:?}", result), - } - } - - #[test] - fn test_none_available_when_no_pieces() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - // Peer has no pieces - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(1), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| false, // Peer has nothing - can_steal: |_| true, - }); - - match result { - AcquireResult::NoneAvailable => {} - _ => panic!("Expected NoneAvailable, got {:?}", result), - } - } - - #[test] - fn test_take_inflight_nonexistent_piece_returns_none() { - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - let piece = tracker - .chunks() - .get_lengths() - .validate_piece_index(0) - .unwrap(); - - // Try to take a piece that's not in-flight - let result = tracker.take_inflight(piece); - assert!(result.is_none()); - } - - #[test] - fn test_steal_piece_from_slowest_peer() { - // Verify that steal picks the piece with the longest elapsed time. - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - let peer_a = peer(1); - let peer_b = peer(2); - - // Peer A reserves piece 0 - let _piece_a1 = match tracker.acquire_piece(AcquireRequest { - peer: peer_a, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - - // Wait a bit so piece_a1 has more elapsed time - std::thread::sleep(Duration::from_millis(10)); - - // Peer A reserves piece 4 (second in iteration order) - let _piece_a2 = match tracker.acquire_piece(AcquireRequest { - peer: peer_a, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => p, - _ => panic!("Expected Reserved"), - }; - - // Wait again so both are stealable - std::thread::sleep(Duration::from_millis(5)); - - // Peer B tries to steal. piece_a1 (piece 0) has been in-flight longer. - // With a very short avg_time, the threshold is easily exceeded. - let result = tracker.acquire_piece(AcquireRequest { - peer: peer_b, - peer_avg_time: Some(Duration::from_millis(1)), - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - match result { - AcquireResult::Stolen { piece, from_peer } => { - // Should steal the piece with the longest elapsed time (piece 0). - assert_eq!(piece.get(), 0, "Should steal slowest piece (piece 0)"); - assert_eq!(from_peer, peer_a); - } - _ => panic!("Expected Stolen, got {:?}", result), - } - } - - #[test] - fn test_multiple_peers_competing() { - // Multiple peers each acquire pieces; verify they get different pieces. - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - let mut acquired_pieces = HashSet::new(); - - for i in 1..=5 { - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(i), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - - match result { - AcquireResult::Reserved(p) => { - assert!( - acquired_pieces.insert(p.get()), - "Peer {i} got duplicate piece {}", - p.get() - ); - } - _ => panic!("Expected Reserved for peer {i}, got {:?}", result), - } - } - - assert_eq!(acquired_pieces.len(), 5); - assert_eq!(tracker.inflight_count(), 5); - } - - #[test] - fn test_all_pieces_completed() { - // Complete all pieces and verify the tracker reflects full completion. - let chunks = make_test_chunk_tracker(3); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(3); - let file_priorities = make_default_file_priorities(&file_infos); - - let mut pieces = Vec::new(); - for i in 1..=3 { - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(i), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - match result { - AcquireResult::Reserved(p) => pieces.push(p), - _ => panic!("Expected Reserved"), - } - } - - // Complete all pieces. - for piece in &pieces { - tracker.take_inflight(*piece); - tracker.mark_piece_hash_ok(*piece); - } - - assert_eq!(tracker.inflight_count(), 0); - - // All pieces should be marked as have. - for piece in &pieces { - assert!(tracker.chunks().is_piece_have(*piece)); - } - - // Trying to acquire more should yield NoneAvailable. - let result = tracker.acquire_piece(AcquireRequest { - peer: peer(10), - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }); - match result { - AcquireResult::NoneAvailable => {} - _ => panic!("Expected NoneAvailable after all pieces completed"), - } - } - - #[test] - fn test_steal_only_pieces_peer_has() { - // This test verifies the fix for a bug where try_steal didn't check - // if the stealing peer actually has the piece in their bitfield. - // Without this check, a peer could "steal" a piece they can't download, - // leaving it stuck in inflight forever. - - let chunks = make_test_chunk_tracker(5); - let mut tracker = PieceTracker::new(chunks); - - let file_infos = make_test_file_infos(5); - let file_priorities = make_default_file_priorities(&file_infos); - - let peer_a = peer(1); - let peer_b = peer(2); - - // Peer A reserves pieces 0 and 4 (first two in iteration order) - let piece_0 = match tracker.acquire_piece(AcquireRequest { - peer: peer_a, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => { - assert_eq!(p.get(), 0); - p - } - _ => panic!("Expected Reserved"), - }; - - let piece_4 = match tracker.acquire_piece(AcquireRequest { - peer: peer_a, - peer_avg_time: None, - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |_| true, - can_steal: |_| true, - }) { - AcquireResult::Reserved(p) => { - assert_eq!(p.get(), 4); - p - } - _ => panic!("Expected Reserved"), - }; - - // Sleep briefly so pieces become stealable - std::thread::sleep(Duration::from_millis(5)); - - // Peer B tries to acquire with: - // - Very short avg_time (1ms) so 3x threshold = 3ms < 5ms elapsed - // - peer_has_piece returns true ONLY for piece 4, NOT piece 0 - let result = tracker.acquire_piece(AcquireRequest { - peer: peer_b, - peer_avg_time: Some(Duration::from_millis(1)), - priority_pieces: std::iter::empty(), - file_priorities: &file_priorities, - file_infos: &file_infos, - peer_has_piece: |p| p.get() == 4, // Peer B only has piece 4 - can_steal: |_| true, - }); - - // Should steal piece 4 (which peer B has), NOT piece 0 (which peer B doesn't have) - match result { - AcquireResult::Stolen { piece, from_peer } => { - assert_eq!(piece, piece_4, "Should steal piece 4 (the one peer B has)"); - assert_eq!(from_peer, peer_a); - // Verify piece 0 is still owned by peer A (wasn't stolen) - assert_eq!(tracker.get_inflight(piece_0).unwrap().peer, peer_a); - } - _ => panic!("Expected Stolen, got {:?}", result), - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/read_buf.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/read_buf.rs deleted file mode 100644 index f03a86de..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/read_buf.rs +++ /dev/null @@ -1,521 +0,0 @@ -use std::{io::IoSliceMut, time::Duration}; - -use crate::{Error, Result}; -use crate::{ - peer_connection::with_timeout, type_aliases::BoxAsyncReadVectored, - vectored_traits::AsyncReadVectoredExt, -}; -use futures::TryFutureExt; -use peer_binary_protocol::{Handshake, Message, MessageDeserializeError}; -use tokio::io::AsyncReadExt; - -// This should be greater than MAX_MSG_LEN -const BUFLEN: usize = 0x8000; // 32kb - -/// A ringbuffer for reading bittorrent messages from socket. -/// Messages may thus span 2 slices (notably, Piece message and UtMetadata messages), which is reflected in their contents. -pub struct ReadBuf { - buf: Box<[u8; BUFLEN]>, - start: usize, - len: usize, -} - -/// Advance by N bytes. A macro so that existing field-level -/// borrows are understood by the borrow checker. -macro_rules! advance { - ($self:expr, $len:expr) => { - $self.len = $self.len.saturating_sub($len); - $self.start = ($self.start + $len) % BUFLEN; - }; -} - -/// Convert readbuf into 2 slices. A macro so that field-level -/// borrows are understood by the borrow checker. -macro_rules! as_slices { - ($self:expr) => {{ - let (first, second) = $self.as_slice_ranges(); - (&$self.buf[first], &$self.buf[second]) - }}; -} - -impl ReadBuf { - pub fn new() -> Self { - Self { - buf: Box::new([0u8; BUFLEN]), - start: 0, - len: 0, - } - } - - // Read the BT handshake. - // This MUST be run as the first operation on the buffer. - pub async fn read_handshake( - &mut self, - conn: &mut BoxAsyncReadVectored, - timeout: Duration, - ) -> Result { - self.len = with_timeout( - "reading", - timeout, - conn.read(&mut *self.buf).map_err(Error::ReadHandshake), - ) - .await?; - if self.len == 0 { - return Err(Error::PeerDisconnectedReadingHandshake); - } - let (h, size) = - Handshake::deserialize(&self.buf[..self.len]).map_err(Error::DeserializeHandshake)?; - self.advance(size); - Ok(h) - } - - fn is_contiguous(&self) -> bool { - self.start + self.len == (self.start + self.len) % BUFLEN - } - - // In rare cases, we might need to make the buffer contiguous, as the message - // parsing code won't work with a split ringbuffer. Only "bitfield" and "extended" messages - // need contiguous buffers. - // "Bitfield" however is always sent early, so in practice it won't ever happen. For "extended" - // in practice, it would rarely happen with UtMetadata::Data messages if the split happens to land - // in the middle of bencoded data (as we only can deserialize bencode from a single slice). - fn make_contiguous(&mut self) -> Result<()> { - if self.is_contiguous() { - return Err(Error::BugReadBufMakeContiguous { - start: u16::try_from(self.start).unwrap_or(u16::MAX), - len: u16::try_from(self.len).unwrap_or(u16::MAX), - }); - } - - // This should be so rare that it's not worth writing complex in-place code. - // See the source in VecDequeue::make_contiguous to see how involved it would be - // otherwise. - let mut new = [0u8; BUFLEN]; - let (first, second) = as_slices!(self); - new[..first.len()].copy_from_slice(first); - new[first.len()..first.len() + second.len()].copy_from_slice(second); - *self.buf = new; - self.start = 0; - Ok(()) - } - - /// Convert into 2 slices (as ranges). - fn as_slice_ranges(&self) -> (std::ops::Range, std::ops::Range) { - // These .min() calls are for asm to be branchless and the code panicless. - let len = self.len.min(BUFLEN); - let start = self.start.min(BUFLEN); - - let first_len = len.min(BUFLEN - start); - let first = start..start + first_len; - let second = 0..len.saturating_sub(first_len); - (first, second) - } - - fn advance(&mut self, len: usize) { - advance!(self, len); - } - - fn is_full(&self) -> bool { - self.len == BUFLEN - } - - /// Get a part of the buffer for reading into. - fn unfilled_ioslices(&mut self) -> [IoSliceMut<'_>; 2] { - let write_start = (self.start.saturating_add(self.len)) % BUFLEN; - let available_len = BUFLEN.saturating_sub(self.len); - let first_len = (BUFLEN.saturating_sub(write_start)).min(available_len); - let second_len = available_len.saturating_sub(first_len); - - let (second, first) = self.buf.split_at_mut(write_start); - - let first_len = first_len.min(first.len()); - let second_len = second_len.min(second.len()); - [ - IoSliceMut::new(&mut first[..first_len]), - IoSliceMut::new(&mut second[..second_len]), - ] - } - - /// Read a message into the buffer, try to deserialize it and call the callback on it. - pub async fn read_message( - &mut self, - conn: &mut BoxAsyncReadVectored, - timeout: Duration, - ) -> Result> { - loop { - let err = { - // A workaround for borrow-checker not understanding early returns. - // - // A stacked reborrow of self. After this block we either return from - // the function, or the block returns an owned error. - // - // Safety: there's nothing inherently unsafe here, this is just a - // borrow checker workaround. However to ensure we respect aliasing - // rules (&mut references are exclusive), there's a miri test below. - // - // In this case, 2 &mut references DO exist lexically, but in reality the - // latter one is a reborrow that ends with the block. - // - // Run with `cargo +nightly miri test -p librtbit --features miri test_read_buf_miri -- --nocapture --ignore` - let this = unsafe { &mut *(self as *mut Self) }; - let (first, second) = as_slices!(this); - match Message::deserialize(first, second) { - Ok((msg, size)) => { - advance!(this, size); - return Ok(msg); - } - Err(e) => e, - } - }; - - match err { - MessageDeserializeError::NotEnoughData(mut need_additional_bytes, ..) => { - while need_additional_bytes > 0 { - if self.is_full() { - return Err(Error::ReadBufFull { - need_additional_bytes: u16::try_from(need_additional_bytes) - .unwrap_or(u16::MAX), - }); - } - let size = with_timeout( - "reading", - timeout, - conn.read_vectored(&mut self.unfilled_ioslices()) - .map_err(Error::Write), - ) - .await?; - if size == 0 { - return Err(Error::PeerDisconnected); - } - self.len += size; - need_additional_bytes = need_additional_bytes.saturating_sub(size) - } - } - MessageDeserializeError::NeedContiguous => { - self.make_contiguous()?; - continue; - } - e => return Err(Error::Deserialize(e)), - } - } - } -} - -#[cfg(test)] -mod tests { - use librtbit_core::constants::CHUNK_SIZE; - use peer_binary_protocol::{ - MAX_MSG_LEN, Message, Piece, - extended::{ - ExtendedMessage, PeerExtendedMessageIds, - ut_metadata::{UtMetadata, UtMetadataData}, - }, - }; - use std::{net::Ipv4Addr, task::Poll, time::Duration}; - use tokio::io::{AsyncRead, AsyncWriteExt}; - - use crate::{ - tests::test_util::setup_test_logging, type_aliases::BoxAsyncReadVectored, - vectored_traits::AsyncReadVectored, - }; - - use super::{BUFLEN, ReadBuf}; - - #[test] - fn test_ringbuf_ranges() { - let mut b = ReadBuf::new(); - assert_eq!(b.as_slice_ranges(), (0..0, 0..0)); - - b.start = 10; - b.len = 10; - assert_eq!(b.as_slice_ranges(), (10..20, 0..0)); - - b.start = BUFLEN - 100; - b.len = 100; - assert_eq!(b.as_slice_ranges(), (BUFLEN - 100..BUFLEN, 0..0)); - - b.start = BUFLEN - 100; - b.len = 120; - assert_eq!(b.as_slice_ranges(), (BUFLEN - 100..BUFLEN, 0..20)); - - b.start = BUFLEN - 100; - b.len = BUFLEN; - assert_eq!(b.as_slice_ranges(), (BUFLEN - 100..BUFLEN, 0..BUFLEN - 100)); - } - - #[test] - fn test_ringbuf_advance() { - let mut b = ReadBuf::new(); - - b.start = 10; - b.len = 10; - assert_eq!(b.as_slice_ranges(), (10..20, 0..0)); - b.advance(5); - assert_eq!(b.as_slice_ranges(), (15..20, 0..0)); - - b.start = BUFLEN - 5; - b.len = 10; - assert_eq!(b.as_slice_ranges(), (BUFLEN - 5..BUFLEN, 0..5)); - b.advance(5); - assert_eq!(b.as_slice_ranges(), (0..5, 0..0)); - } - - #[test] - fn test_ringbuf_make_contiguous() { - let mut b = ReadBuf::new(); - assert!(b.is_contiguous()); - assert!(b.make_contiguous().is_err()); - - fn fill(buf: &mut [u8], value: u8) { - for b in buf.iter_mut() { - *b = value; - } - } - - fill(&mut b.buf[BUFLEN - 100..], 42); - fill(&mut b.buf[..200], 43); - b.start = BUFLEN - 100; - b.len = 300; - assert!(!b.is_contiguous()); - assert!(b.make_contiguous().is_ok()); - assert_eq!(b.len, 300); - assert_eq!(b.start, 0); - assert_eq!(&b.buf[..100], &[42u8; 100]); - assert_eq!(&b.buf[100..300], &[43u8; 200]); - assert_eq!(&b.buf[300..], &[0u8; BUFLEN - 300]); - } - - #[test] - fn test_unfilled_ioslices() { - ReadBuf::new(); - - fn offset(buf: &ReadBuf, s: *const u8, len: usize) -> std::ops::Range { - if len == 0 { - return 0..0; - } - let offset = unsafe { s.byte_offset_from(buf.buf.as_ptr()) } as usize; - offset..offset + len - } - - fn offsets( - buf: &ReadBuf, - ioslices: [(*const u8, usize); 2], - ) -> [std::ops::Range; 2] { - [ - offset(buf, ioslices[0].0, ioslices[0].1), - offset(buf, ioslices[1].0, ioslices[1].1), - ] - } - - #[track_caller] - fn assert_one(start: usize, len: usize, ranges: [std::ops::Range; 2]) { - let mut b = ReadBuf::new(); - b.start = start; - b.len = len; - let [f, s] = b.unfilled_ioslices(); - let slices = [(f.as_ptr(), f.len()), (s.as_ptr(), s.len())]; - assert_eq!(offsets(&b, slices), ranges) - } - - // full - assert_one(0, BUFLEN, [0..0, 0..0]); - assert_one(100, BUFLEN, [0..0, 0..0]); - assert_one(BUFLEN, BUFLEN, [0..0, 0..0]); - - // start=0 - assert_one(0, 100, [100..BUFLEN, 0..0]); - assert_one(0, BUFLEN - 100, [BUFLEN - 100..BUFLEN, 0..0]); - assert_one(0, BUFLEN - 1, [BUFLEN - 1..BUFLEN, 0..0]); - - // start=N - assert_one(100, 100, [200..BUFLEN, 0..100]); - assert_one(100, BUFLEN - 100, [0..100, 0..0]); - assert_one(100, BUFLEN - 101, [BUFLEN - 1..BUFLEN, 0..100]); - - // start=BUFLEN-1 - assert_one(BUFLEN - 1, 100, [99..BUFLEN - 1, 0..0]); - } - - #[tokio::test] - async fn can_read_long_metainfo_correctly() { - setup_test_logging(); - let reader = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) - .await - .unwrap(); - let port = reader.local_addr().unwrap().port(); - let mut writer = tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, port)) - .await - .unwrap() - .into_split() - .1; - - const ITERATIONS: u32 = 4096; - - let reader = async { - let reader = reader.accept().await.unwrap().0.into_split().0; - let mut reader: BoxAsyncReadVectored = Box::new(reader); - let mut rb = ReadBuf::new(); - - for piece in 0..ITERATIONS { - let msg = rb - .read_message(&mut reader, Duration::from_millis(1000)) - .await - .unwrap(); - let utdata = match msg { - Message::Extended(ExtendedMessage::UtMetadata(UtMetadata::Data(utdata))) => { - utdata - } - other => panic!("expected utdata, got {other:?}"), - }; - assert_eq!(utdata.len(), CHUNK_SIZE as usize); - assert_eq!(utdata.piece(), piece); - #[allow(clippy::cast_possible_truncation)] - let expected_byte = { piece as u8 }; - let all_good = utdata - .as_double_buf() - .get() - .into_iter() - .flatten() - .all(|b| *b == expected_byte); - if !all_good { - panic!("broken data"); - } - } - }; - - let pext = PeerExtendedMessageIds::my(); - let writer = async { - let mut sbuf = [0u8; MAX_MSG_LEN]; - let mut pbuf = [0u8; CHUNK_SIZE as usize]; - for piece in 0..ITERATIONS { - #[allow(clippy::cast_possible_truncation)] - for b in pbuf.iter_mut() { - *b = piece as u8; - } - let len = Message::Extended(ExtendedMessage::UtMetadata(UtMetadata::Data( - UtMetadataData::from_bytes(piece, CHUNK_SIZE * ITERATIONS, pbuf[..].into()), - ))) - .serialize(&mut sbuf, &|| pext) - .unwrap(); - writer.write_all(&sbuf[..len]).await.unwrap(); - } - }; - - tokio::join!(reader, writer); - } - - /// A test to prove that unsafe usage in read_buf is not UB. - #[test] - #[ignore = "run with --features=miri only, doesn't work with tokio"] - fn test_read_buf_miri() { - struct BufWrap(std::vec::IntoIter); - impl AsyncRead for BufWrap { - fn poll_read( - self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - _buf: &mut tokio::io::ReadBuf<'_>, - ) -> Poll> { - unimplemented!("don't need this") - } - } - - impl AsyncReadVectored for BufWrap { - fn poll_read_vectored( - self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - vec: &mut [std::io::IoSliceMut<'_>], - ) -> Poll> { - // Yield one byte at a time to ensure that all code paths are executed - // (both NotEnoughData and Ok). - let this = self.get_mut(); - let byte = match this.0.next() { - Some(byte) => byte, - None => return Poll::Ready(Ok(0)), - }; - let target = vec.iter_mut().find(|s| !s.is_empty()).unwrap(); - target[0] = byte; - Poll::Ready(Ok(1)) - } - } - - let mut rb = ReadBuf::new(); - rb.start = BUFLEN - 15; - - let mut src = { - let mut buf = vec![0u8; 64]; - let piece = Message::Piece(Piece::from_data(42, 43, b"hello")); - let len = piece.serialize(&mut buf, &|| Default::default()).unwrap(); - buf.truncate(len); - Box::new(BufWrap(buf.into_iter())) as BoxAsyncReadVectored - }; - - pollster::block_on(async { - let msg = rb.read_message(&mut src, Duration::ZERO).await.unwrap(); - match msg { - Message::Piece(p) => { - assert_eq!(p.index, 42); - assert_eq!(p.begin, 43); - assert_eq!(p.data(), (b"he".as_slice(), b"llo".as_slice())); - } - _ => unreachable!(), - } - assert!(matches!( - rb.read_message(&mut src, Duration::ZERO) - .await - .expect_err("expected error"), - crate::Error::PeerDisconnected, - )); - }) - } - - #[test] - fn test_read_buf_error_large_values() { - // Verify that error fields saturate to u16::MAX instead of truncating - // when the source value exceeds u16::MAX. - - // Values within u16 range - let val: usize = 100; - assert_eq!(u16::try_from(val).unwrap_or(u16::MAX), 100); - - let val: usize = u16::MAX as usize; - assert_eq!(u16::try_from(val).unwrap_or(u16::MAX), u16::MAX); - - // Values exceeding u16::MAX should saturate to u16::MAX - let val: usize = u16::MAX as usize + 1; - assert_eq!(u16::try_from(val).unwrap_or(u16::MAX), u16::MAX); - - let val: usize = BUFLEN; // 32768, which fits in u16 - let expected = u16::try_from(val).unwrap_or(u16::MAX); - // BUFLEN is 0x8000 = 32768, which fits in u16 - assert_eq!(expected, u16::try_from(BUFLEN).unwrap()); - - // Test the actual error construction path with large values - let large_start: usize = 100_000; - let large_len: usize = 200_000; - let err = crate::Error::BugReadBufMakeContiguous { - start: u16::try_from(large_start).unwrap_or(u16::MAX), - len: u16::try_from(large_len).unwrap_or(u16::MAX), - }; - match err { - crate::Error::BugReadBufMakeContiguous { start, len } => { - assert_eq!(start, u16::MAX); - assert_eq!(len, u16::MAX); - } - _ => panic!("unexpected error variant"), - } - - // Test ReadBufFull error with large value - let large_need: usize = 1_000_000; - let err = crate::Error::ReadBufFull { - need_additional_bytes: u16::try_from(large_need).unwrap_or(u16::MAX), - }; - match err { - crate::Error::ReadBufFull { - need_additional_bytes, - } => { - assert_eq!(need_additional_bytes, u16::MAX); - } - _ => panic!("unexpected error variant"), - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/config.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/config.rs deleted file mode 100644 index 6fb0ee9a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/config.rs +++ /dev/null @@ -1,34 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// RSS feed configuration for automatic torrent downloading. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RssFeedConfig { - /// Display name for the feed - pub name: String, - /// Feed URL (RSS 2.0 or Atom) - pub url: String, - /// How often to poll, in seconds (default 900 = 15 minutes) - #[serde(default = "default_poll_interval")] - pub poll_interval_secs: u64, - /// Category to assign to downloaded torrents - #[serde(default)] - pub category: Option, - /// Regex pattern to filter feed entries by title - #[serde(default)] - pub filter_regex: Option, - /// Whether this feed is active - #[serde(default = "default_true")] - pub enabled: bool, - /// Auto-download all items from this feed (no rules needed). - /// Ignored when filter_regex is set (use download rules instead). - #[serde(default)] - pub auto_download: bool, -} - -fn default_poll_interval() -> u64 { - 900 -} - -fn default_true() -> bool { - true -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/db.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/db.rs deleted file mode 100644 index 8cbd2e19..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/db.rs +++ /dev/null @@ -1,357 +0,0 @@ -use std::path::Path; - -use chrono::Utc; -use rusqlite::{Connection, params}; -use tracing::info; - -use super::models::{RssItem, RssRule}; - -/// Database handle for RSS feed item and rule persistence. -pub struct RssDatabase { - conn: Connection, -} - -impl RssDatabase { - /// Open (or create) the RSS database at the given path. - pub fn open(path: &Path) -> anyhow::Result { - let conn = Connection::open(path)?; - conn.execute_batch("PRAGMA journal_mode=WAL;")?; - conn.execute_batch("PRAGMA foreign_keys=ON;")?; - let db = Self { conn }; - db.migrate()?; - Ok(db) - } - - fn migrate(&self) -> anyhow::Result<()> { - self.conn.execute_batch( - "CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER NOT NULL - );", - )?; - - let version: u32 = self - .conn - .query_row( - "SELECT COALESCE(MAX(version), 0) FROM schema_version", - [], - |row| row.get(0), - ) - .unwrap_or(0); - - if version < 1 { - info!("Applying RSS database migration v1"); - self.conn.execute_batch( - " - CREATE TABLE IF NOT EXISTS rss_items ( - id TEXT PRIMARY KEY, - feed_name TEXT NOT NULL, - title TEXT NOT NULL, - url TEXT, - published_at TEXT, - first_seen_at TEXT NOT NULL, - downloaded INTEGER NOT NULL DEFAULT 0, - downloaded_at TEXT, - category TEXT, - size_bytes INTEGER DEFAULT 0 - ); - - CREATE INDEX IF NOT EXISTS idx_rss_items_feed ON rss_items(feed_name); - CREATE INDEX IF NOT EXISTS idx_rss_items_seen ON rss_items(first_seen_at DESC); - - CREATE TABLE IF NOT EXISTS rss_rules ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - feed_name TEXT NOT NULL, - category TEXT, - priority INTEGER NOT NULL DEFAULT 1, - match_regex TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1 - ); - - INSERT INTO schema_version (version) VALUES (1); - ", - )?; - } - - // Store RSS feed configs in the database so they persist across restarts - // without needing a config file. - if version < 2 { - info!("Applying RSS database migration v2"); - self.conn.execute_batch( - " - CREATE TABLE IF NOT EXISTS rss_feeds ( - name TEXT PRIMARY KEY, - url TEXT NOT NULL, - poll_interval_secs INTEGER NOT NULL DEFAULT 900, - category TEXT, - filter_regex TEXT, - enabled INTEGER NOT NULL DEFAULT 1, - auto_download INTEGER NOT NULL DEFAULT 0 - ); - - UPDATE schema_version SET version = 2; - ", - )?; - } - - Ok(()) - } - - // ----------------------------------------------------------------------- - // RSS feed config operations (stored in DB for API-managed feeds) - // ----------------------------------------------------------------------- - - /// List all RSS feed configs from the database. - pub fn feed_list(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT name, url, poll_interval_secs, category, filter_regex, enabled, auto_download - FROM rss_feeds ORDER BY name ASC", - )?; - let feeds = stmt - .query_map([], |row| { - Ok(super::config::RssFeedConfig { - name: row.get(0)?, - url: row.get(1)?, - poll_interval_secs: row.get::<_, i64>(2)? as u64, - category: row.get(3)?, - filter_regex: row.get(4)?, - enabled: row.get::<_, i32>(5)? != 0, - auto_download: row.get::<_, i32>(6)? != 0, - }) - })? - .collect::, _>>()?; - Ok(feeds) - } - - /// Insert or replace an RSS feed config. - pub fn feed_upsert(&self, feed: &super::config::RssFeedConfig) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR REPLACE INTO rss_feeds (name, url, poll_interval_secs, category, filter_regex, enabled, auto_download) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - feed.name, - feed.url, - feed.poll_interval_secs as i64, - feed.category, - feed.filter_regex, - feed.enabled as i32, - feed.auto_download as i32, - ], - )?; - Ok(()) - } - - /// Delete an RSS feed config by name. - pub fn feed_delete(&self, name: &str) -> anyhow::Result<()> { - self.conn - .execute("DELETE FROM rss_feeds WHERE name = ?1", params![name])?; - Ok(()) - } - - // ----------------------------------------------------------------------- - // RSS item operations - // ----------------------------------------------------------------------- - - /// Upsert an RSS feed item (insert or ignore if already exists). - pub fn rss_item_upsert(&self, item: &RssItem) -> anyhow::Result<()> { - self.conn.execute( - "INSERT OR IGNORE INTO rss_items (id, feed_name, title, url, published_at, - first_seen_at, downloaded, downloaded_at, category, size_bytes) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - item.id, - item.feed_name, - item.title, - item.url, - item.published_at.map(|d| d.to_rfc3339()), - item.first_seen_at.to_rfc3339(), - item.downloaded as i32, - item.downloaded_at.map(|d| d.to_rfc3339()), - item.category, - item.size_bytes as i64, - ], - )?; - Ok(()) - } - - /// Check if an RSS item ID already exists. - pub fn rss_item_exists(&self, id: &str) -> anyhow::Result { - let count: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM rss_items WHERE id = ?1", - params![id], - |row| row.get(0), - )?; - Ok(count > 0) - } - - /// List RSS items, optionally filtered by feed name. - pub fn rss_items_list( - &self, - feed_name: Option<&str>, - limit: usize, - ) -> anyhow::Result> { - if let Some(name) = feed_name { - let mut stmt = self.conn.prepare( - "SELECT id, feed_name, title, url, published_at, first_seen_at, - downloaded, downloaded_at, category, size_bytes - FROM rss_items WHERE feed_name = ?1 - ORDER BY first_seen_at DESC LIMIT ?2", - )?; - let items = stmt - .query_map(params![name, limit as i64], |row| self.map_rss_item(row))? - .collect::, _>>()?; - return Ok(items); - } - - let mut stmt = self.conn.prepare( - "SELECT id, feed_name, title, url, published_at, first_seen_at, - downloaded, downloaded_at, category, size_bytes - FROM rss_items ORDER BY first_seen_at DESC LIMIT ?1", - )?; - let items = stmt - .query_map(params![limit as i64], |row| self.map_rss_item(row))? - .collect::, _>>()?; - Ok(items) - } - - /// Get a single RSS item by ID. - pub fn rss_item_get(&self, id: &str) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT id, feed_name, title, url, published_at, first_seen_at, - downloaded, downloaded_at, category, size_bytes - FROM rss_items WHERE id = ?1", - )?; - let result = stmt.query_row(params![id], |row| self.map_rss_item(row)); - match result { - Ok(item) => Ok(Some(item)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Mark an RSS item as downloaded. - pub fn rss_item_mark_downloaded(&self, id: &str, category: Option<&str>) -> anyhow::Result<()> { - self.conn.execute( - "UPDATE rss_items SET downloaded = 1, downloaded_at = ?2, category = ?3 WHERE id = ?1", - params![id, Utc::now().to_rfc3339(), category], - )?; - Ok(()) - } - - /// Count total RSS items. - pub fn rss_item_count(&self) -> anyhow::Result { - let count: i64 = self - .conn - .query_row("SELECT COUNT(*) FROM rss_items", [], |row| row.get(0))?; - Ok(usize::try_from(count).unwrap_or(0)) - } - - /// Prune RSS items to keep only the N most recent. - pub fn rss_items_prune(&self, keep: usize) -> anyhow::Result { - let deleted = self.conn.execute( - "DELETE FROM rss_items WHERE id NOT IN ( - SELECT id FROM rss_items ORDER BY first_seen_at DESC LIMIT ?1 - )", - params![keep as i64], - )?; - Ok(deleted) - } - - fn map_rss_item(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(RssItem { - id: row.get(0)?, - feed_name: row.get(1)?, - title: row.get(2)?, - url: row.get(3)?, - published_at: row.get::<_, Option>(4)?.map(|s| parse_datetime(&s)), - first_seen_at: parse_datetime(&row.get::<_, String>(5)?), - downloaded: row.get::<_, i32>(6)? != 0, - downloaded_at: row.get::<_, Option>(7)?.map(|s| parse_datetime(&s)), - category: row.get(8)?, - size_bytes: row.get::<_, i64>(9)? as u64, - }) - } - - // ----------------------------------------------------------------------- - // RSS rule operations - // ----------------------------------------------------------------------- - - /// Insert a new RSS download rule. - pub fn rss_rule_insert(&self, rule: &RssRule) -> anyhow::Result<()> { - let feed_names_str = rule.feed_names.join(","); - self.conn.execute( - "INSERT INTO rss_rules (id, name, feed_name, category, priority, match_regex, enabled) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - params![ - rule.id, - rule.name, - feed_names_str, - rule.category, - rule.priority, - rule.match_regex, - rule.enabled as i32, - ], - )?; - Ok(()) - } - - /// List all RSS download rules. - pub fn rss_rule_list(&self) -> anyhow::Result> { - let mut stmt = self.conn.prepare( - "SELECT id, name, feed_name, category, priority, match_regex, enabled - FROM rss_rules ORDER BY name ASC", - )?; - let rules = stmt - .query_map([], |row| { - let feed_names_str: String = row.get(2)?; - let feed_names: Vec = feed_names_str - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - Ok(RssRule { - id: row.get(0)?, - name: row.get(1)?, - feed_names, - category: row.get(3)?, - priority: row.get(4)?, - match_regex: row.get(5)?, - enabled: row.get::<_, i32>(6)? != 0, - }) - })? - .collect::, _>>()?; - Ok(rules) - } - - /// Update an RSS download rule. - pub fn rss_rule_update(&self, rule: &RssRule) -> anyhow::Result<()> { - let feed_names_str = rule.feed_names.join(","); - self.conn.execute( - "UPDATE rss_rules SET name=?2, feed_name=?3, category=?4, priority=?5, - match_regex=?6, enabled=?7 WHERE id=?1", - params![ - rule.id, - rule.name, - feed_names_str, - rule.category, - rule.priority, - rule.match_regex, - rule.enabled as i32, - ], - )?; - Ok(()) - } - - /// Delete an RSS download rule. - pub fn rss_rule_delete(&self, id: &str) -> anyhow::Result<()> { - self.conn - .execute("DELETE FROM rss_rules WHERE id=?1", params![id])?; - Ok(()) - } -} - -fn parse_datetime(s: &str) -> chrono::DateTime { - chrono::DateTime::parse_from_rfc3339(s) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/mod.rs deleted file mode 100644 index 7e307521..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod config; -pub mod db; -pub mod models; -pub mod monitor; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/models.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/models.rs deleted file mode 100644 index e3f50386..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/models.rs +++ /dev/null @@ -1,46 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// A discovered item from an RSS feed, persisted in the database. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RssItem { - /// Feed entry ID (from the RSS feed) - pub id: String, - /// Name of the feed this came from - pub feed_name: String, - /// Title of the entry - pub title: String, - /// Torrent download URL (torrent file or magnet link) - pub url: Option, - /// When the entry was published (from feed) - pub published_at: Option>, - /// When we first saw this item - pub first_seen_at: DateTime, - /// Whether this item has been downloaded - pub downloaded: bool, - /// When it was downloaded (if applicable) - pub downloaded_at: Option>, - /// Category used when downloaded - pub category: Option, - /// Size in bytes (if available from feed) - pub size_bytes: u64, -} - -/// A download rule that automatically enqueues matching RSS feed items. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RssRule { - /// Unique rule identifier - pub id: String, - /// Human-readable name for the rule - pub name: String, - /// Which feed(s) this rule applies to (one or more feed names) - pub feed_names: Vec, - /// Category to assign to downloaded torrents - pub category: Option, - /// Download priority (0=low, 1=normal, 2=high, 3=force) - pub priority: i32, - /// Regex to match against feed item titles - pub match_regex: String, - /// Whether this rule is active - pub enabled: bool, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/monitor.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/monitor.rs deleted file mode 100644 index 8fe41d9a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/rss/monitor.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::sync::Arc; - -use chrono::Utc; -use parking_lot::Mutex; -use tracing::{info, warn}; - -use super::config::RssFeedConfig; -use super::db::RssDatabase; -use super::models::RssItem; -use crate::api::Api; -use crate::session::AddTorrent; - -/// Background RSS feed monitor that polls configured feeds for torrent links, -/// persists all discovered items to the database, and automatically enqueues -/// items that match download rules. -pub struct RssMonitor { - db: Arc>, - api: Api, - rss_history_limit: Option, -} - -impl RssMonitor { - pub fn new(db: Arc>, api: Api, rss_history_limit: Option) -> Self { - Self { - db, - api, - rss_history_limit, - } - } - - /// Run the monitor loop forever, polling feeds at their configured intervals. - pub async fn run(self) { - info!("Starting RSS monitor"); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .expect("Failed to create HTTP client"); - - loop { - // Load feeds from the database each iteration so API changes are picked up - let feeds = { - let db = self.db.lock(); - db.feed_list().unwrap_or_default() - }; - - if feeds.is_empty() { - // No feeds configured, sleep and check again - tokio::time::sleep(tokio::time::Duration::from_secs(60)).await; - continue; - } - - for feed in &feeds { - if !feed.enabled { - continue; - } - - if let Err(e) = self.check_feed(&client, feed).await { - warn!(feed = %feed.name, error = %e, "RSS feed check failed"); - } - } - - // Prune old items based on config - self.prune_items(); - - // Use the minimum poll interval across all enabled feeds, defaulting to 15 min - let interval = feeds - .iter() - .filter(|f| f.enabled) - .map(|f| f.poll_interval_secs) - .min() - .unwrap_or(900); - - tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; - } - } - - fn prune_items(&self) { - let limit = self.rss_history_limit.unwrap_or(500); - let db = self.db.lock(); - if let Ok(count) = db.rss_item_count() - && count > limit - && let Ok(pruned) = db.rss_items_prune(limit) - && pruned > 0 - { - info!(pruned, "Pruned old RSS items"); - } - } - - async fn check_feed( - &self, - client: &reqwest::Client, - feed: &RssFeedConfig, - ) -> anyhow::Result<()> { - info!(feed = %feed.name, url = %feed.url, "Checking RSS feed"); - - let response = client.get(&feed.url).send().await?; - let body = response.bytes().await?; - let parsed = feed_rs::parser::parse(&body[..])?; - - // Compile filter regex if provided - let filter = feed - .filter_regex - .as_ref() - .and_then(|r| regex::Regex::new(r).ok()); - - // Load download rules for this feed - let rules = { - let db = self.db.lock(); - db.rss_rule_list() - .unwrap_or_default() - .into_iter() - .filter(|r| r.enabled && r.feed_names.iter().any(|n| n == &feed.name)) - .collect::>() - }; - - let mut new_items = 0; - - for entry in &parsed.entries { - let title = entry - .title - .as_ref() - .map(|t| t.content.clone()) - .unwrap_or_default(); - let entry_id = entry.id.clone(); - - // Find torrent URL from links or media content - let torrent_url = Self::extract_torrent_url(entry); - - // Extract size from enclosure/media if available - let size_bytes = entry - .media - .iter() - .flat_map(|m| &m.content) - .filter_map(|c| c.size) - .next() - .unwrap_or(0); - - // Extract published date - let published_at = entry.published.or(entry.updated); - - // Persist ALL items to DB (regardless of filter) - let item = RssItem { - id: entry_id.clone(), - feed_name: feed.name.clone(), - title: title.clone(), - url: torrent_url.clone(), - published_at, - first_seen_at: Utc::now(), - downloaded: false, - downloaded_at: None, - category: feed.category.clone(), - size_bytes, - }; - - // Check if already in DB (dedup) - let already_exists = { - let db = self.db.lock(); - db.rss_item_exists(&entry_id).unwrap_or(false) - }; - - if !already_exists { - let db = self.db.lock(); - let _ = db.rss_item_upsert(&item); - new_items += 1; - } else { - continue; // Already processed - } - - let Some(ref url) = torrent_url else { - continue; - }; - - // Check if this item should be auto-downloaded: - // 1. Feed-level filter must pass (if set) - let passes_filter = match filter { - Some(ref re) => re.is_match(&title), - None => true, - }; - - if !passes_filter { - continue; - } - - // 2. Check download rules - let matched_rule = rules.iter().find(|r| { - regex::Regex::new(&r.match_regex) - .map(|re| re.is_match(&title)) - .unwrap_or(false) - }); - - let (should_download, category) = if let Some(rule) = matched_rule { - ( - true, - rule.category.clone().or_else(|| feed.category.clone()), - ) - } else if feed.auto_download && feed.filter_regex.is_none() { - (true, feed.category.clone()) - } else { - (false, None) - }; - - if !should_download { - continue; - } - - info!(feed = %feed.name, title = %title, url = %url, "Auto-downloading RSS item"); - - match self.add_torrent(url, category.as_deref()).await { - Ok(()) => { - let db = self.db.lock(); - let _ = db.rss_item_mark_downloaded(&entry_id, category.as_deref()); - info!(title = %title, "RSS torrent added successfully"); - } - Err(e) => { - warn!(title = %title, error = %e, "Failed to add RSS torrent"); - } - } - } - - if new_items > 0 { - info!(feed = %feed.name, new_items, "RSS feed check complete"); - } - - Ok(()) - } - - /// Extract torrent URL from a feed entry's links or media content. - /// Looks for .torrent files, magnet links, or application/x-bittorrent media types. - fn extract_torrent_url(entry: &feed_rs::model::Entry) -> Option { - // Check for magnet links first - entry - .links - .iter() - .find(|l| l.href.starts_with("magnet:")) - .map(|l| l.href.clone()) - .or_else(|| { - // Check for .torrent file links - entry - .links - .iter() - .find(|l| { - l.href.ends_with(".torrent") - || l.media_type - .as_deref() - .is_some_and(|mt| mt == "application/x-bittorrent") - }) - .map(|l| l.href.clone()) - }) - .or_else(|| { - // Check media content for torrent URLs - entry - .media - .iter() - .flat_map(|m| &m.content) - .find(|c| { - c.url.as_ref().is_some_and(|u| { - let s = u.as_str(); - s.ends_with(".torrent") || s.starts_with("magnet:") - }) - }) - .and_then(|c| c.url.as_ref().map(|u| u.to_string())) - }) - .or_else(|| { - // Check enclosure-style links (RSS 2.0 enclosures often appear as links) - entry - .links - .iter() - .find(|l| { - l.media_type - .as_deref() - .is_some_and(|mt| mt.contains("torrent") || mt.contains("magnet")) - }) - .map(|l| l.href.clone()) - }) - .or_else(|| { - // Fall back to first link as last resort - entry.links.first().map(|l| l.href.clone()) - }) - } - - /// Add a torrent to the session from a URL (magnet or .torrent URL). - async fn add_torrent(&self, url: &str, category: Option<&str>) -> anyhow::Result<()> { - use std::borrow::Cow; - - let opts = crate::session::AddTorrentOptions { - overwrite: true, - category: category.map(|s| s.to_string()), - ..Default::default() - }; - - let add = AddTorrent::Url(Cow::Owned(url.to_string())); - - self.api - .session() - .add_torrent(add, Some(opts)) - .await - .map_err(|e| anyhow::anyhow!("Failed to add torrent: {:#}", e))?; - - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/helpers.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/helpers.rs deleted file mode 100644 index 44c42313..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/helpers.rs +++ /dev/null @@ -1,263 +0,0 @@ -use std::{collections::HashSet, path::Path}; - -use anyhow::{Context, bail}; -use buffers::ByteBufOwned; -use futures::{Stream, stream::BoxStream}; -use librtbit_core::torrent_metainfo::ValidatedTorrentMetaV1Info; -use tracing::{debug, info, warn}; - -use crate::{merge_streams::merge_streams, storage::TorrentStorage, type_aliases::FileInfos}; - -use super::types::ParsedTorrentFile; -use super::types::torrent_from_bytes; - -pub(super) async fn torrent_from_url( - reqwest_client: &reqwest::Client, - url: &str, -) -> anyhow::Result { - let response = reqwest_client - .get(url) - .send() - .await - .context("error downloading torrent metadata")?; - if !response.status().is_success() { - bail!("GET {} returned {}", url, response.status()) - } - let b = response - .bytes() - .await - .with_context(|| format!("error reading response body from {url}"))?; - torrent_from_bytes(b).context("error decoding torrent") -} - -pub(super) fn compute_only_files_regex>( - torrent: &ValidatedTorrentMetaV1Info, - filename_re: &str, -) -> anyhow::Result> { - let filename_re = regex::Regex::new(filename_re).context("filename regex is incorrect")?; - let mut only_files = Vec::new(); - for (idx, fd) in torrent.iter_file_details().enumerate() { - let full_path = fd.filename.to_pathbuf(); - let Some(path_str) = full_path.to_str() else { - continue; - }; - if filename_re.is_match(path_str) { - only_files.push(idx); - } - } - if only_files.is_empty() { - bail!("none of the filenames match the given regex") - } - Ok(only_files) -} - -pub(super) fn compute_only_files( - info: &ValidatedTorrentMetaV1Info, - only_files: Option>, - only_files_regex: Option, - list_only: bool, -) -> anyhow::Result>> { - match (only_files, only_files_regex) { - (Some(_), Some(_)) => { - bail!("only_files and only_files_regex are mutually exclusive"); - } - (Some(only_files), None) => { - let total_files = info.iter_file_lengths().count(); - for id in only_files.iter().copied() { - if id >= total_files { - bail!("file id {} is out of range", id); - } - } - Ok(Some(only_files)) - } - (None, Some(filename_re)) => { - let only_files = compute_only_files_regex(info, &filename_re)?; - for (idx, fd) in info.iter_file_details().enumerate() { - if !only_files.contains(&idx) { - continue; - } - if !list_only { - info!(filename=?fd.filename, "will download"); - } - } - Ok(Some(only_files)) - } - (None, None) => Ok(None), - } -} - -pub(super) fn merge_two_optional_streams( - s1: Option + Unpin + Send + 'static>, - s2: Option + Unpin + Send + 'static>, -) -> Option> { - match (s1, s2) { - (Some(s1), None) => Some(Box::pin(s1)), - (None, Some(s2)) => Some(Box::pin(s2)), - (Some(s1), Some(s2)) => Some(Box::pin(merge_streams(s1, s2))), - (None, None) => None, - } -} - -pub(crate) fn remove_files_and_dirs(infos: &FileInfos, files: &dyn TorrentStorage) { - let mut all_dirs = HashSet::new(); - for (id, fi) in infos.iter().enumerate() { - if fi.attrs.padding { - continue; - } - let mut fname = &*fi.relative_filename; - if let Err(e) = files.remove_file(id, fname) { - warn!(?fi.relative_filename, error=?e, "could not delete file"); - } else { - debug!(?fi.relative_filename, "deleted the file") - } - while let Some(parent) = fname.parent() { - if parent != Path::new("") { - all_dirs.insert(parent); - } - fname = parent; - } - } - - let all_dirs = { - let mut v = all_dirs.into_iter().collect::>(); - v.sort_unstable_by_key(|p| std::cmp::Reverse(p.as_os_str().len())); - v - }; - for dir in all_dirs { - if let Err(e) = files.remove_directory_if_empty(dir) { - warn!("error removing {dir:?}: {e:#}"); - } else { - debug!("removed {dir:?}") - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::create_torrent_file::create_torrent; - use crate::spawn_utils::BlockingSpawner; - use crate::tests::test_util; - use futures::StreamExt; - - /// Helper to create a multi-file torrent and return the parsed validated info. - async fn make_multi_file_torrent_info() -> ValidatedTorrentMetaV1Info { - let dir = tempfile::tempdir().unwrap(); - test_util::create_new_file_with_random_content(&dir.path().join("movie.mp4"), 5000); - test_util::create_new_file_with_random_content(&dir.path().join("subtitle.srt"), 500); - test_util::create_new_file_with_random_content(&dir.path().join("readme.txt"), 200); - - let result = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let bytes = result.as_bytes().unwrap(); - let parsed = torrent_from_bytes(bytes).unwrap(); - parsed.meta.info.data.validate().unwrap() - } - - #[tokio::test] - async fn test_compute_only_files_regex() { - let info = make_multi_file_torrent_info().await; - // Match only .mp4 files - let result = compute_only_files_regex(&info, r"\.mp4$").unwrap(); - assert_eq!(result.len(), 1); - } - - #[tokio::test] - async fn test_compute_only_files_regex_no_match() { - let info = make_multi_file_torrent_info().await; - let result = compute_only_files_regex(&info, r"\.nonexistent$"); - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_compute_only_files_by_index() { - let info = make_multi_file_torrent_info().await; - let result = compute_only_files(&info, Some(vec![0, 1]), None, false).unwrap(); - assert_eq!(result, Some(vec![0, 1])); - } - - #[tokio::test] - async fn test_compute_only_files_invalid_index() { - let info = make_multi_file_torrent_info().await; - let result = compute_only_files(&info, Some(vec![999]), None, false); - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_compute_only_files_mutually_exclusive() { - let info = make_multi_file_torrent_info().await; - let result = compute_only_files(&info, Some(vec![0]), Some(r"\.mp4$".to_string()), false); - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!(err_msg.contains("mutually exclusive")); - } - - #[tokio::test] - async fn test_compute_only_files_none() { - let info = make_multi_file_torrent_info().await; - let result = compute_only_files(&info, None, None, false).unwrap(); - assert!(result.is_none()); - } - - #[tokio::test] - async fn test_merge_two_optional_streams_both_none() { - let none1: Option>> = None; - let none2: Option>> = None; - let result = merge_two_optional_streams(none1, none2); - assert!(result.is_none()); - } - - #[tokio::test] - async fn test_merge_two_optional_streams_first_only() { - let s1 = futures::stream::iter(vec![1, 2, 3]); - let none2: Option>> = None; - let result = merge_two_optional_streams(Some(s1), none2); - assert!(result.is_some()); - let items: Vec<_> = result.unwrap().collect().await; - assert_eq!(items, vec![1, 2, 3]); - } - - #[tokio::test] - async fn test_merge_two_optional_streams_second_only() { - let none1: Option>> = None; - let s2 = futures::stream::iter(vec![4, 5, 6]); - let result = merge_two_optional_streams(none1, Some(s2)); - assert!(result.is_some()); - let items: Vec<_> = result.unwrap().collect().await; - assert_eq!(items, vec![4, 5, 6]); - } - - #[tokio::test] - async fn test_merge_two_optional_streams_both() { - let s1 = futures::stream::iter(vec![1, 2]); - let s2 = futures::stream::iter(vec![3, 4]); - let result = merge_two_optional_streams(Some(s1), Some(s2)); - assert!(result.is_some()); - let mut items: Vec<_> = result.unwrap().collect().await; - items.sort(); - assert_eq!(items, vec![1, 2, 3, 4]); - } - - #[tokio::test] - async fn test_torrent_from_bytes_valid() { - let dir = tempfile::tempdir().unwrap(); - test_util::create_new_file_with_random_content(&dir.path().join("test.bin"), 1000); - - let result = create_torrent(dir.path(), Default::default(), &BlockingSpawner::new(1)) - .await - .unwrap(); - - let bytes = result.as_bytes().unwrap(); - let parsed = torrent_from_bytes(bytes); - assert!(parsed.is_ok()); - } - - #[test] - fn test_torrent_from_bytes_invalid() { - let invalid = bytes::Bytes::from_static(b"this is not a valid torrent file"); - let result = torrent_from_bytes(invalid); - assert!(result.is_err()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/mod.rs deleted file mode 100644 index 8d948154..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/mod.rs +++ /dev/null @@ -1,1448 +0,0 @@ -mod helpers; -mod network; -mod peer_sources; -mod types; - -pub use types::{ - AddTorrent, AddTorrentOptions, AddTorrentResponse, ListOnlyResponse, SessionOptions, - SessionPersistenceConfig, -}; -// Public API: used by the CLI binary -pub(crate) use types::CheckedIncomingConnection; -#[allow(unused_imports)] -pub use types::read_local_file_including_stdin; - -#[cfg(test)] -use types::torrent_file_from_info_bytes; -use types::torrent_from_bytes; - -use std::{ - borrow::Cow, - collections::HashSet, - net::SocketAddr, - path::{Component, Path, PathBuf}, - sync::{Arc, atomic::AtomicUsize}, - time::Duration, -}; - -use crate::{ - ManagedTorrent, ManagedTorrentShared, - api::TorrentIdOrHash, - bitv_factory::{BitVFactory, NonPersistentBitVFactory}, - category::CategoryManager, - ip_ranges::IpRanges, - limits::Limits, - merge_streams::merge_streams, - peer_connection::PeerConnectionOptions, - session_persistence::{SessionPersistenceStore, json::JsonSessionPersistenceStore}, - session_stats::SessionStats, - spawn_utils::BlockingSpawner, - storage::{ - BoxStorageFactory, StorageFactoryExt, TorrentStorage, filesystem::FilesystemStorageFactory, - }, - stream_connect::{SocksProxyConfig, StreamConnector, StreamConnectorArgs}, - torrent_state::{ - ManagedTorrentHandle, ManagedTorrentLocked, ManagedTorrentOptions, ManagedTorrentState, - TorrentMetadata, initializing::TorrentStateInitializing, - }, -}; -use anyhow::{Context, bail}; -use arc_swap::ArcSwapOption; -use buffers::ByteBufOwned; -use dht::{Dht, DhtBuilder, DhtConfig, Id20, PersistentDht}; -use futures::{FutureExt, StreamExt, future::BoxFuture, stream::FuturesUnordered}; -use itertools::Itertools; -use librqbit_utp::BindDevice; -use librtbit_core::{ - crate_version, peer_id::generate_azereus_style, spawn_utils::spawn_with_cancel, - torrent_metainfo::ValidatedTorrentMetaV1Info, -}; -use librtbit_lsd::{LocalServiceDiscovery, LocalServiceDiscoveryOptions}; -use parking_lot::RwLock; -use tokio::sync::Notify; -use tokio_util::sync::{CancellationToken, DropGuard}; -use tracing::{Instrument, debug, debug_span, error, info, trace, warn}; -use tracker_comms::UdpTrackerClient; - -use helpers::{compute_only_files, remove_files_and_dirs}; -use types::{InternalAddResult, SessionDatabase}; - -pub const SUPPORTED_SCHEMES: [&str; 3] = ["http:", "https:", "magnet:"]; - -pub type TorrentId = usize; - -pub struct Session { - // Core state and services - pub(crate) db: RwLock, - next_id: AtomicUsize, - pub(crate) bitv_factory: Arc, - pub(super) spawner: BlockingSpawner, - - // Network - peer_id: Id20, - announce_port: Option, - listen_addr: Option, - dht: Option, - pub(crate) connector: Arc, - reqwest_client: reqwest::Client, - udp_tracker_client: Option, - disable_trackers: bool, - - // Lifecycle management - cancellation_token: CancellationToken, - _cancellation_token_drop_guard: DropGuard, - - // Runtime settings - output_folder: RwLock, - peer_opts: PeerConnectionOptions, - default_storage_factory: Option, - persistence: Option>, - trackers: HashSet, - - lsd: Option, - - // Categories - category_manager: CategoryManager, - - // Limits and throttling - pub(crate) concurrent_initialize_semaphore: Arc, - pub ratelimits: Limits, - pub(crate) peer_limit_atomic: AtomicUsize, - pub(crate) concurrent_init_limit_atomic: AtomicUsize, - - pub blocklist: IpRanges, - pub allowlist: Option, - - // Monitoring / tracing / logging - pub(crate) stats: Arc, - root_span: Option, - - // Feature flags - #[cfg(feature = "disable-upload")] - _disable_upload: bool, - pub ipv4_only: bool, - pub peer_limit: Option, - - /// Configurable cap for probabilistic fastresume validation denominator. - pub(crate) fastresume_validation_denom: Option, - // Move-on-complete: destination folder for finished torrents - completed_folder: RwLock>, -} - -impl Session { - /// Create a new session with default options. - /// The passed in folder will be used as a default unless overridden per torrent. - /// It will run a DHT server/client, a TCP listener and . - #[inline(never)] - pub fn new(default_output_folder: PathBuf) -> BoxFuture<'static, anyhow::Result>> { - Self::new_with_opts(default_output_folder, SessionOptions::default()) - } - - pub fn cancellation_token(&self) -> &CancellationToken { - &self.cancellation_token - } - - pub fn get_peer_limit(&self) -> usize { - self.peer_limit_atomic - .load(std::sync::atomic::Ordering::Relaxed) - } - - pub fn set_peer_limit(&self, limit: usize) { - self.peer_limit_atomic - .store(limit, std::sync::atomic::Ordering::Relaxed); - } - - pub fn get_concurrent_init_limit(&self) -> usize { - self.concurrent_init_limit_atomic - .load(std::sync::atomic::Ordering::Relaxed) - } - - pub fn set_concurrent_init_limit(&self, limit: usize) { - self.concurrent_init_limit_atomic - .store(limit, std::sync::atomic::Ordering::Relaxed); - } - - /// Create a new session with options. - #[inline(never)] - pub fn new_with_opts( - default_output_folder: PathBuf, - mut opts: SessionOptions, - ) -> BoxFuture<'static, anyhow::Result>> { - async move { - let peer_id = opts - .peer_id - .unwrap_or_else(|| generate_azereus_style(*b"rQ", crate_version!())); - let token = opts.cancellation_token.take().unwrap_or_default(); - - #[cfg(feature = "disable-upload")] - if opts.disable_upload { - warn!("uploading disabled"); - } - - let bind_device = match opts.bind_device_name.as_ref() { - Some(name) => Some( - BindDevice::new_from_name(name) - .with_context(|| format!("error creating bind device {name}"))?, - ), - None => None, - }; - - let listen_result = if let Some(listen_opts) = opts.listen.take() { - Some( - listen_opts - .start( - opts.root_span.as_ref().and_then(|s| s.id()), - token.child_token(), - bind_device.as_ref(), - ) - .await - .context("error starting listeners")?, - ) - } else { - None - }; - - let has_proxy = opts.connect.as_ref().and_then(|s| s.proxy_url.as_ref()).is_some(); - - let dht = if opts.disable_dht || has_proxy { - if has_proxy && !opts.disable_dht { - warn!("DHT disabled: SOCKS proxy configured (DHT uses UDP which cannot be proxied)"); - } - None - } else { - let dht = if opts.disable_dht_persistence { - DhtBuilder::with_config(DhtConfig { - bootstrap_addrs: opts.dht_bootstrap_addrs.clone(), - cancellation_token: Some(token.child_token()), - bind_device: bind_device.as_ref(), - ..Default::default() - }) - .await - .context("error initializing DHT")? - } else { - let pdht_config = opts.dht_config.take().unwrap_or_default(); - PersistentDht::create( - Some(pdht_config), - Some(token.clone()), - bind_device.as_ref(), - ) - .await - .context("error initializing persistent DHT")? - }; - - Some(dht) - }; - let peer_opts = opts - .connect - .as_ref() - .and_then(|p| p.peer_opts) - .unwrap_or_default(); - - async fn persistence_factory( - opts: &SessionOptions, - spawner: BlockingSpawner, - ) -> anyhow::Result<( - Option>, - Arc, - )> { - macro_rules! make_result { - ($store:expr) => { - if opts.fastresume { - Ok((Some($store.clone()), $store)) - } else { - Ok((Some($store), Arc::new(NonPersistentBitVFactory {}))) - } - }; - } - - match &opts.persistence { - Some(SessionPersistenceConfig::Json { folder }) => { - let folder = match folder.as_ref() { - Some(f) => f.clone(), - None => SessionPersistenceConfig::default_json_persistence_folder()?, - }; - - let s = Arc::new( - JsonSessionPersistenceStore::new(folder, spawner) - .await - .context("error initializing JsonSessionPersistenceStore")?, - ); - - make_result!(s) - } - #[cfg(feature = "postgres")] - Some(SessionPersistenceConfig::Postgres { connection_string }) => { - use crate::session_persistence::postgres::PostgresSessionStorage; - let p = Arc::new(PostgresSessionStorage::new(connection_string).await?); - make_result!(p) - } - None => Ok((None, Arc::new(NonPersistentBitVFactory {}))), - } - } - - const DEFAULT_BLOCKING_THREADS_IF_NOT_SET: usize = 8; - let spawner = BlockingSpawner::new( - opts.runtime_worker_threads - .unwrap_or(DEFAULT_BLOCKING_THREADS_IF_NOT_SET), - ); - - let (persistence, bitv_factory) = persistence_factory(&opts, spawner.clone()) - .await - .context("error initializing session persistence store")?; - - let proxy_url = opts.connect.as_ref().and_then(|s| s.proxy_url.as_ref()); - let proxy_config = match proxy_url { - Some(pu) => Some( - SocksProxyConfig::parse(pu) - .with_context(|| format!("error parsing proxy url {pu}"))?, - ), - None => None, - }; - - let reqwest_client = { - let builder = if let Some(proxy_url) = proxy_url { - let proxy = reqwest::Proxy::all(proxy_url) - .context("error creating socks5 proxy for HTTP")?; - reqwest::Client::builder().proxy(proxy) - } else { - #[allow(unused_mut)] - let mut b = reqwest::Client::builder(); - #[cfg(not(windows))] - if let Some(bd) = opts.bind_device_name.as_ref() { - b = b.interface(bd); - } - b - }; - - builder.build().context("error building HTTP(S) client")? - }; - - let stream_connector = Arc::new( - StreamConnector::new(StreamConnectorArgs { - enable_tcp: opts.connect.as_ref().map(|c| c.enable_tcp).unwrap_or(true), - socks_proxy_config: proxy_config, - utp_socket: listen_result.as_ref().and_then(|l| l.utp_socket.clone()), - bind_device: bind_device.clone(), - ipv4_only: opts.ipv4_only, - }) - .await - .context("error creating stream connector")?, - ); - - let blocklist = if let Some(blocklist_url) = opts.blocklist_url { - info!(url = blocklist_url, "loading p2p blocklist"); - let bl = IpRanges::load_from_url(&blocklist_url) - .await - .with_context(|| format!("error reading blocklist from {blocklist_url}"))?; - info!(len = bl.len(), "loaded blocklist"); - bl - } else { - IpRanges::default() - }; - - let allowlist = if let Some(allowlist_url) = opts.allowlist_url { - info!(url = allowlist_url, "loading p2p allowlist"); - let al = IpRanges::load_from_url(&allowlist_url) - .await - .with_context(|| format!("error reading allowlist from {allowlist_url}"))?; - info!(len = al.len(), "loaded allowlist"); - Some(al) - } else { - None - }; - - let udp_tracker_client = if has_proxy { - warn!("UDP trackers disabled: SOCKS proxy configured"); - None - } else { - Some( - UdpTrackerClient::new(token.clone(), bind_device.as_ref()) - .await - .context("error creating UDP tracker client")?, - ) - }; - - let category_manager = if let Some(p) = persistence.as_ref() { - match p.load_categories().await { - Ok(cats) => CategoryManager::from_categories(cats), - Err(e) => { - warn!("error loading categories from persistence: {e:#}"); - CategoryManager::new() - } - } - } else { - CategoryManager::new() - }; - - let lsd = { - if opts.disable_local_service_discovery || has_proxy { - if has_proxy && !opts.disable_local_service_discovery { - warn!("Local service discovery disabled: SOCKS proxy configured (LSD uses multicast which cannot be proxied)"); - } - None - } else { - LocalServiceDiscovery::new(LocalServiceDiscoveryOptions { - cancel_token: token.clone(), - bind_device: bind_device.as_ref(), - ..Default::default() - }) - .await - .inspect_err(|e| warn!("error starting local service discovery: {e:#}")) - .ok() - } - }; - - let resolved_peer_limit = opts.peer_limit.unwrap_or(200); - let resolved_concurrent_init_limit = opts.concurrent_init_limit.unwrap_or(3); - - let session = Arc::new(Self { - persistence, - bitv_factory, - peer_id, - dht, - peer_opts, - spawner: spawner.clone(), - output_folder: RwLock::new(default_output_folder), - next_id: AtomicUsize::new(0), - db: RwLock::new(Default::default()), - _cancellation_token_drop_guard: token.clone().drop_guard(), - cancellation_token: token, - announce_port: listen_result.as_ref().and_then(|l| l.announce_port), - listen_addr: listen_result.as_ref().map(|l| l.addr), - default_storage_factory: opts.default_storage_factory, - reqwest_client, - connector: stream_connector, - root_span: opts.root_span, - stats: Arc::new(SessionStats::new()), - concurrent_initialize_semaphore: Arc::new(tokio::sync::Semaphore::new( - resolved_concurrent_init_limit, - )), - udp_tracker_client, - ratelimits: Limits::new(opts.ratelimits), - peer_limit_atomic: AtomicUsize::new(resolved_peer_limit), - concurrent_init_limit_atomic: AtomicUsize::new(resolved_concurrent_init_limit), - ipv4_only: opts.ipv4_only, - trackers: opts.trackers, - disable_trackers: opts.disable_trackers, - peer_limit: opts.peer_limit.or(Some(200)), - - #[cfg(feature = "disable-upload")] - _disable_upload: opts.disable_upload, - blocklist, - allowlist, - lsd, - fastresume_validation_denom: opts.fastresume_validation_denom, - category_manager, - completed_folder: RwLock::new(opts.completed_folder), - }); - - if let Some(mut listen) = listen_result { - if let Some(tcp) = listen.tcp_socket.take() { - session.spawn( - debug_span!(parent: session.rs(), "tcp_listen", addr = ?listen.addr), - "tcp_listen", - { - let this = session.clone(); - async move { this.task_listener(tcp).await } - }, - ); - } - if let Some(utp) = listen.utp_socket.take() { - session.spawn( - debug_span!(parent: session.rs(), "utp_listen", addr = ?listen.addr), - "utp_listen", - { - let this = session.clone(); - async move { this.task_listener(utp).await } - }, - ); - } - if listen.enable_upnp_port_forwarding - && let Some(announce_port) = listen.announce_port - { - info!(port = announce_port, "starting UPnP port forwarder"); - let bind_device = bind_device.clone(); - session.spawn( - debug_span!(parent: session.rs(), "upnp_forward", port = announce_port), - "upnp_forward", - Self::task_upnp_port_forwarder(announce_port, bind_device), - ); - } - } - - if let Some(persistence) = session.persistence.as_ref() { - info!("will use {persistence:?} for session persistence"); - - let mut ps = persistence.stream_all().await?; - let mut added_all = false; - let mut futs = FuturesUnordered::new(); - - while !added_all || !futs.is_empty() { - // NOTE: this closure exists purely to workaround rustfmt screwing up when inlining it. - let add_torrent_span = |info_hash: &Id20| -> tracing::Span { - debug_span!(parent: session.rs(), "add_torrent", info_hash=?info_hash) - }; - tokio::select! { - Some(res) = futs.next(), if !futs.is_empty() => { - if let Err(e) = res { - error!("error adding torrent to session: {e:#}"); - } - } - st = ps.next(), if !added_all => { - match st { - Some(st) => { - let (id, st) = st?; - let span = add_torrent_span(st.info_hash()); - let (add_torrent, mut opts) = st.into_add_torrent()?; - opts.preferred_id = Some(id); - let fut = session.add_torrent(add_torrent, Some(opts)); - let fut = fut.instrument(span); - futs.push(fut); - }, - None => added_all = true - }; - } - }; - } - } - - session.start_speed_estimator_updater(); - - Ok(session) - } - .boxed() - } - - pub fn get_dht(&self) -> Option<&Dht> { - self.dht.as_ref() - } - - pub(super) fn merge_peer_opts( - &self, - other: Option, - ) -> PeerConnectionOptions { - let other = match other { - Some(o) => o, - None => self.peer_opts, - }; - PeerConnectionOptions { - connect_timeout: other.connect_timeout.or(self.peer_opts.connect_timeout), - read_write_timeout: other - .read_write_timeout - .or(self.peer_opts.read_write_timeout), - keep_alive_interval: other - .keep_alive_interval - .or(self.peer_opts.keep_alive_interval), - } - } - - /// Spawn a task in the context of the session. - #[track_caller] - pub fn spawn( - &self, - span: tracing::Span, - name: impl Into>, - fut: impl std::future::Future> + Send + 'static, - ) { - spawn_with_cancel(span, name, self.cancellation_token.clone(), fut); - } - - pub(crate) fn rs(&self) -> Option { - self.root_span.as_ref().and_then(|s| s.id()) - } - - /// Stop the session and all managed tasks. - pub async fn stop(&self) { - let torrents = self - .db - .read() - .torrents - .values() - .cloned() - .collect::>(); - for torrent in torrents { - if let Err(e) = torrent.pause() { - debug!("error pausing torrent: {e:#}"); - } - } - self.cancellation_token.cancel(); - // this sucks, but hopefully will be enough - tokio::time::sleep(Duration::from_secs(1)).await; - } - - /// Run a callback given the currently managed torrents. - pub fn with_torrents( - &self, - callback: impl Fn(&mut dyn Iterator) -> R, - ) -> R { - callback(&mut self.db.read().torrents.iter().map(|(id, t)| (*id, t))) - } - - /// Add a torrent to the session. - #[inline(never)] - pub fn add_torrent<'a>( - self: &'a Arc, - add: AddTorrent<'a>, - opts: Option, - ) -> BoxFuture<'a, anyhow::Result> { - async move { - let mut opts = opts.unwrap_or_default(); - let add_res = match add { - AddTorrent::Url(magnet) if magnet.starts_with("magnet:") || magnet.len() == 40 => { - let magnet = librtbit_core::magnet::Magnet::parse(&magnet) - .context("provided path is not a valid magnet URL")?; - let info_hash = magnet - .as_id20() - .context("magnet link didn't contain a BTv1 infohash")?; - if let Some(so) = magnet.get_select_only() { - // Only overwrite opts.only_files if user didn't specify - if opts.only_files.is_none() { - opts.only_files = Some(so); - } - } - - InternalAddResult { - info_hash, - trackers: magnet - .trackers - .into_iter() - .filter_map(|t| url::Url::parse(&t).ok()) - .collect(), - metadata: None, - name: magnet.name, - web_seed_urls: Vec::new(), - } - } - other => { - let torrent = match other { - AddTorrent::Url(url) - if url.starts_with("http://") || url.starts_with("https://") => - { - helpers::torrent_from_url(&self.reqwest_client, &url).await? - } - AddTorrent::Url(url) => { - bail!( - "unsupported URL {:?}. Supporting magnet:, http:, and https", - url - ) - } - AddTorrent::TorrentFileBytes(bytes) => { - torrent_from_bytes(bytes).context("error decoding torrent")? - } - }; - - let mut trackers = torrent - .meta - .iter_announce() - .unique() - .filter_map(|tracker| match std::str::from_utf8(tracker.as_ref()) { - Ok(url) => Some(url.to_owned()), - Err(_) => { - warn!("cannot parse tracker url as utf-8, ignoring"); - None - } - }) - .collect::>(); - if let Some(custom_trackers) = opts.trackers.clone() { - trackers.extend(custom_trackers); - } - - let web_seed_urls: Vec = torrent - .meta - .url_list - .iter() - .flat_map(|ul| ul.iter()) - .filter_map(|url| std::str::from_utf8(url.as_ref()).ok()) - .map(|s| s.to_owned()) - .collect(); - - InternalAddResult { - info_hash: torrent.meta.info_hash, - metadata: Some(TorrentMetadata::new( - torrent.meta.info.data.validate()?, - torrent.torrent_bytes, - torrent.meta.info.raw_bytes.0, - )?), - trackers: trackers - .iter() - .filter_map(|t| url::Url::parse(t).ok()) - .collect(), - name: None, - web_seed_urls, - } - } - }; - - self.add_torrent_internal(add_res, opts).await - } - .instrument(debug_span!(parent: self.rs(), "add_torrent")) - .boxed() - } - - fn get_default_subfolder_for_torrent( - &self, - info: &ValidatedTorrentMetaV1Info, - magnet_name: Option<&str>, - ) -> anyhow::Result> { - let files = info - .iter_file_details() - .map(|fd| Ok((fd.filename.to_pathbuf(), fd.len))) - .collect::>>()?; - if files.len() < 2 { - return Ok(None); - } - - fn check_valid(pb: &Path) -> anyhow::Result<()> { - if pb.components().any(|x| !matches!(x, Component::Normal(_))) { - bail!("path traversal in torrent name detected") - } - Ok(()) - } - - if let Some(name) = info.name() - && !name.is_empty() - { - let pb = PathBuf::from(name.as_ref()); - check_valid(&pb)?; - return Ok(Some(pb)); - }; - if let Some(name) = magnet_name { - let pb = PathBuf::from(name); - check_valid(&pb)?; - return Ok(Some(pb)); - } - // Let the subfolder name be the longest filename - let longest = files - .iter() - .max_by_key(|(_, l)| l) - .unwrap() - .0 - .file_stem() - .context("can't determine longest filename")?; - Ok::<_, anyhow::Error>(Some(PathBuf::from(longest))) - } - - async fn add_torrent_internal( - self: &Arc, - add_res: InternalAddResult, - mut opts: AddTorrentOptions, - ) -> anyhow::Result { - let InternalAddResult { - info_hash, - metadata, - trackers, - name, - web_seed_urls, - } = add_res; - - let private = metadata.as_ref().is_some_and(|m| m.info.info().private); - - let make_peer_rx = || { - self.make_peer_rx( - info_hash, - trackers.clone(), - !opts.paused && !opts.list_only, - opts.force_tracker_interval, - opts.initial_peers.clone().unwrap_or_default(), - private, - ) - }; - - let mut seen_peers = Vec::new(); - - let (metadata, peer_rx) = { - match metadata { - Some(metadata) => { - let mut peer_rx = None; - if !opts.paused && !opts.list_only { - peer_rx = make_peer_rx(); - } - (metadata, peer_rx) - } - None => { - let peer_rx = make_peer_rx().context( - "no known way to resolve peers (no DHT, no trackers, no initial_peers)", - )?; - - const DEFAULT_MAGNET_TIMEOUT_SECS: u64 = 120; - let timeout_secs = opts - .magnet_resolution_timeout_secs - .unwrap_or(DEFAULT_MAGNET_TIMEOUT_SECS); - - let resolve_fut = - self.resolve_magnet(info_hash, peer_rx, &trackers, opts.peer_opts); - - let resolved_magnet = if timeout_secs == 0 { - resolve_fut.await? - } else { - tokio::time::timeout(Duration::from_secs(timeout_secs), resolve_fut) - .await - .context(format!( - "magnet metadata resolution timed out after {timeout_secs}s" - ))?? - }; - - // Add back seen_peers into the peer stream, as we consumed some peers - // while resolving the magnet. - seen_peers = resolved_magnet.seen_peers.clone(); - let peer_rx = Some( - merge_streams( - resolved_magnet.peer_rx, - futures::stream::iter(resolved_magnet.seen_peers), - ) - .boxed(), - ); - (resolved_magnet.metadata, peer_rx) - } - } - }; - - trace!("Torrent metadata: {:#?}", &metadata.info.info()); - - let only_files = compute_only_files( - &metadata.info, - opts.only_files, - opts.only_files_regex, - opts.list_only, - )?; - - let output_folder = match (opts.output_folder, opts.sub_folder) { - (None, None) => self.output_folder.read().join( - self.get_default_subfolder_for_torrent(&metadata.info, name.as_deref())? - .unwrap_or_default(), - ), - (Some(o), None) => PathBuf::from(o), - (Some(_), Some(_)) => { - bail!("you can't provide both output_folder and sub_folder") - } - (None, Some(s)) => self.output_folder.read().join(s), - }; - - if opts.list_only { - return Ok(AddTorrentResponse::ListOnly(ListOnlyResponse { - info_hash, - info: metadata.info, - only_files, - output_folder, - seen_peers, - torrent_bytes: metadata.torrent_bytes, - })); - } - - let storage_factory = opts - .storage_factory - .take() - .or_else(|| self.default_storage_factory.as_ref().map(|f| f.clone_box())) - .unwrap_or_else(|| FilesystemStorageFactory::default().boxed()); - - let id = if let Some(id) = opts.preferred_id { - id - } else if let Some(p) = self.persistence.as_ref() { - p.next_id().await? - } else { - self.next_id - .fetch_add(1, std::sync::atomic::Ordering::Relaxed) - }; - - let _permit = self.spawner.semaphore().acquire_owned().await?; - - let (managed_torrent, metadata) = { - let mut g = self.db.write(); - // Check for duplicate by ID or info_hash using the index - let existing = g.torrents.get(&id).map(|t| (id, t.clone())).or_else(|| { - g.get_by_info_hash(info_hash) - .map(|(eid, t)| (eid, t.clone())) - }); - if let Some((id, handle)) = existing { - return Ok(AddTorrentResponse::AlreadyManaged(id, handle)); - } - - let span = debug_span!(parent: self.rs(), "torrent", id); - let peer_opts = self.merge_peer_opts(opts.peer_opts); - let metadata = Arc::new(metadata); - let minfo = Arc::new(ManagedTorrentShared { - id, - span, - info_hash, - trackers: trackers.into_iter().collect(), - spawner: self.spawner.clone(), - peer_id: self.peer_id, - storage_factory, - options: ManagedTorrentOptions { - force_tracker_interval: opts.force_tracker_interval, - peer_connect_timeout: peer_opts.connect_timeout, - peer_read_write_timeout: peer_opts.read_write_timeout, - allow_overwrite: opts.overwrite, - output_folder, - ratelimits: opts.ratelimits, - initial_peers: opts.initial_peers.clone().unwrap_or_default(), - peer_limit: opts.peer_limit.or(self.peer_limit), - #[cfg(feature = "disable-upload")] - _disable_upload: self._disable_upload, - }, - connector: self.connector.clone(), - session: Arc::downgrade(self), - magnet_name: name, - web_seed_urls, - category: RwLock::new(opts.category.clone()), - cancellation_token: parking_lot::Mutex::new(self.cancellation_token.child_token()), - }); - - let initializing = Arc::new(TorrentStateInitializing::new( - minfo.clone(), - metadata.clone(), - only_files.clone(), - self.spawner - .block_in_place(|| minfo.storage_factory.create_and_init(&minfo, &metadata))?, - false, - )); - let handle = Arc::new(ManagedTorrent { - locked: RwLock::new(ManagedTorrentLocked { - paused: opts.paused, - state: ManagedTorrentState::Initializing(initializing), - only_files, - }), - state_change_notify: Notify::new(), - shared: minfo, - metadata: ArcSwapOption::new(Some(metadata.clone())), - }); - - g.add_torrent(handle.clone(), id); - (handle, metadata) - }; - - if let Some(p) = self.persistence.as_ref() - && let Err(e) = p.store(id, &managed_torrent).await - { - self.db.write().remove_torrent(&id); - return Err(e); - } - - let _e = managed_torrent.shared.span.clone().entered(); - - managed_torrent - .start(peer_rx, opts.paused) - .context("error starting torrent")?; - - if let Some(name) = metadata.info.name() { - info!(?name, "added torrent"); - } - - // Spawn a completion watcher if a completed_folder is configured - if self.completed_folder.read().is_some() { - self.spawn_completion_watcher(id, managed_torrent.clone()); - } - - Ok(AddTorrentResponse::Added(id, managed_torrent)) - } - - pub fn get(&self, id: TorrentIdOrHash) -> Option { - let db = self.db.read(); - match id { - TorrentIdOrHash::Id(id) => db.torrents.get(&id).cloned(), - TorrentIdOrHash::Hash(info_hash) => { - db.get_by_info_hash(info_hash).map(|(_, v)| v.clone()) - } - } - } - - pub async fn delete(&self, id: TorrentIdOrHash, delete_files: bool) -> anyhow::Result<()> { - let id = match id { - TorrentIdOrHash::Id(id) => id, - TorrentIdOrHash::Hash(h) => self - .db - .read() - .get_by_info_hash(h) - .map(|(id, _)| id) - .context("no such torrent in db")?, - }; - let removed = self - .db - .write() - .remove_torrent(&id) - .with_context(|| format!("torrent with id {id} did not exist"))?; - - // Cancel the per-torrent token to stop any running init/check tasks. - // This must happen before pause() so initializing torrents are promptly cancelled. - removed.shared.cancel_token(); - - if let Err(e) = removed.pause() { - debug!("error pausing torrent before deletion: {e:#}") - } - - let metadata = removed - .metadata - .load_full() - .context("torrent metadata was not loaded")?; - - let storage = removed - .with_state_mut(|s| match s.take() { - ManagedTorrentState::Initializing(p) => p.files.take().ok(), - ManagedTorrentState::Paused(p) => Some(p.files), - ManagedTorrentState::Live(l) => l - .pause() - // inspect_err not available in 1.75 - .map_err(|e| { - warn!(?id, "error pausing torrent: {e:#}"); - e - }) - .ok() - .map(|p| p.files), - _ => None, - }) - .map(Ok) - .unwrap_or_else(|| { - removed - .shared - .storage_factory - .create(removed.shared(), &metadata) - }); - - if let Some(p) = self.persistence.as_ref() { - if let Err(e) = p.delete(id).await { - error!( - ?id, - "error deleting torrent from persistence database: {e:#}" - ); - } else { - debug!(?id, "deleted torrent from persistence database") - } - } - - match (storage, delete_files) { - (Err(e), true) => return Err(e).context("torrent deleted, but could not delete files"), - (Ok(storage), true) => { - debug!("will delete files"); - remove_files_and_dirs(&metadata.file_infos, &storage); - if removed.shared().options.output_folder != *self.output_folder.read() - && let Err(e) = storage.remove_directory_if_empty(Path::new("")) - { - warn!( - ?id, - "error removing {:?}: {e:#}", - removed.shared().options.output_folder - ) - } - } - (_, false) => { - debug!("not deleting files") - } - }; - - info!(id, "deleted torrent"); - Ok(()) - } - - pub(crate) async fn try_update_persistence_metadata(&self, handle: &ManagedTorrentHandle) { - if let Some(p) = self.persistence.as_ref() - && let Err(e) = p.update_metadata(handle.id(), handle).await - { - warn!(storage=?p, error=?e, "error updating metadata") - } - } - - pub async fn pause(&self, handle: &ManagedTorrentHandle) -> anyhow::Result<()> { - handle.pause()?; - self.try_update_persistence_metadata(handle).await; - Ok(()) - } - - pub async fn unpause(self: &Arc, handle: &ManagedTorrentHandle) -> anyhow::Result<()> { - let peer_rx = self.make_peer_rx_managed_torrent(handle, true); - handle.start(peer_rx, false)?; - self.try_update_persistence_metadata(handle).await; - Ok(()) - } - - pub async fn update_only_files( - self: &Arc, - handle: &ManagedTorrentHandle, - only_files: &HashSet, - ) -> anyhow::Result<()> { - handle.update_only_files(only_files)?; - self.try_update_persistence_metadata(handle).await; - Ok(()) - } - - /// Get the configured completed folder. - pub fn completed_folder(&self) -> Option { - self.completed_folder.read().clone() - } - - /// Set the completed folder (move-on-complete destination). - pub fn set_completed_folder(&self, folder: Option) { - *self.completed_folder.write() = folder; - } - - /// Get the default download/output folder. - pub fn output_folder(&self) -> PathBuf { - self.output_folder.read().clone() - } - - /// Set the default download/output folder. - pub fn set_output_folder(&self, folder: PathBuf) { - *self.output_folder.write() = folder; - } - - /// Spawn a task that watches for a torrent to complete and moves its files - /// to the completed folder. - fn spawn_completion_watcher(self: &Arc, id: TorrentId, handle: ManagedTorrentHandle) { - let session = Arc::downgrade(self); - let handle_weak = Arc::downgrade(&handle); - let span = handle.shared.span.clone(); - self.spawn( - debug_span!(parent: span, "completion_watcher", id), - format!("[{id}]completion_watcher"), - async move { - // Wait for the torrent to finish downloading - if let Err(e) = handle.wait_until_completed().await { - debug!(id, "torrent errored while waiting for completion: {e:#}"); - return Ok(()); - } - - let session = match session.upgrade() { - Some(s) => s, - None => return Ok(()), - }; - - let completed_folder = match session.completed_folder.read().clone() { - Some(f) => f, - None => return Ok(()), - }; - - let handle = match handle_weak.upgrade() { - Some(h) => h, - None => return Ok(()), - }; - - info!(id, target_folder=?completed_folder, "torrent completed, moving to completed folder"); - - if let Err(e) = session - .move_completed_torrent(id, &handle, completed_folder) - .await - { - error!(id, "error moving completed torrent: {e:#}"); - } - Ok(()) - }, - ); - } - - /// Move all files of a completed torrent from its current output folder - /// to the target folder. - async fn move_completed_torrent( - &self, - id: TorrentId, - handle: &ManagedTorrentHandle, - target_folder: PathBuf, - ) -> anyhow::Result<()> { - let current_output = handle.shared().options.output_folder.clone(); - - // Don't move if already in the target folder - if current_output == target_folder { - debug!( - id, - "torrent is already in the completed folder, skipping move" - ); - return Ok(()); - } - - let metadata = handle - .metadata - .load_full() - .context("torrent metadata not loaded")?; - - // Determine the subfolder name: if the torrent's output_folder differs from - // the session's default output_folder, it means there's a subfolder (e.g. for - // multi-file torrents). We want to replicate the relative structure under the - // completed folder. - let relative_path = current_output - .strip_prefix(&*self.output_folder.read()) - .unwrap_or(Path::new("")); - - let dest_base = if relative_path == Path::new("") { - target_folder.clone() - } else { - target_folder.join(relative_path) - }; - - // Create the destination base directory - tokio::fs::create_dir_all(&dest_base) - .await - .with_context(|| format!("error creating completed directory {dest_base:?}"))?; - - // Move each file - for fi in metadata.file_infos.iter() { - if fi.attrs.padding { - continue; - } - - let src = current_output.join(&fi.relative_filename); - let dst = dest_base.join(&fi.relative_filename); - - // Create parent directories for the destination if needed - if let Some(parent) = dst.parent() { - tokio::fs::create_dir_all(parent) - .await - .with_context(|| format!("error creating directory {parent:?}"))?; - } - - // Try rename first (fast, same filesystem) - match tokio::fs::rename(&src, &dst).await { - Ok(()) => { - debug!(id, ?src, ?dst, "moved file via rename"); - } - Err(rename_err) => { - // Rename failed (likely cross-device), fall back to copy + delete - debug!( - id, - ?src, - ?dst, - "rename failed ({rename_err}), falling back to copy+delete" - ); - tokio::fs::copy(&src, &dst) - .await - .with_context(|| format!("error copying {src:?} to {dst:?}"))?; - tokio::fs::remove_file(&src).await.with_context(|| { - format!("error removing source file {src:?} after copy") - })?; - debug!(id, ?src, ?dst, "moved file via copy+delete"); - } - } - } - - // Try to clean up empty source directories - if current_output != *self.output_folder.read() - && let Err(e) = tokio::fs::remove_dir(¤t_output).await - { - debug!( - id, - ?current_output, - "could not remove source directory (may not be empty): {e}" - ); - } - - info!(id, ?dest_base, "completed torrent moved successfully"); - - // Note: we don't update the torrent's output_folder or persistence here because - // the torrent is already finished. The files are now in the completed folder. - // If we wanted to update persistence for the new location, the ManagedTorrentOptions - // output_folder would need to be mutable (wrapped in RwLock), which is a larger change. - // For now, the move is fire-and-forget from the torrent's perspective. - - Ok(()) - } - - pub fn listen_addr(&self) -> Option { - self.listen_addr - } - - pub fn announce_port(&self) -> Option { - self.announce_port - } - - pub fn categories(&self) -> &CategoryManager { - &self.category_manager - } - - pub fn set_torrent_category( - &self, - id: TorrentIdOrHash, - category: Option, - ) -> anyhow::Result<()> { - let handle = self.get(id).context("torrent not found")?; - *handle.shared().category.write() = category; - Ok(()) - } - - pub async fn persist_categories(&self) -> anyhow::Result<()> { - if let Some(p) = self.persistence.as_ref() { - let snapshot = self.category_manager.snapshot(); - p.store_categories(&snapshot).await?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use buffers::ByteBuf; - use itertools::Itertools; - use librtbit_core::torrent_metainfo::{TorrentMetaV1, torrent_from_bytes}; - - use super::torrent_file_from_info_bytes; - - #[test] - fn test_torrent_file_from_info_and_bytes() { - fn get_trackers(info: &TorrentMetaV1) -> Vec { - info.iter_announce() - .filter_map(|t| std::str::from_utf8(t.as_ref()).ok().map(|t| t.to_owned())) - .filter_map(|t| t.parse().ok()) - .collect_vec() - } - - let orig_full_torrent = - include_bytes!("../../resources/ubuntu-21.04-desktop-amd64.iso.torrent"); - let parsed = torrent_from_bytes(&orig_full_torrent[..]).unwrap(); - let parsed_trackers = get_trackers(&parsed); - - let generated_torrent = - torrent_file_from_info_bytes(parsed.info.raw_bytes.as_ref(), &parsed_trackers).unwrap(); - let generated_parsed = torrent_from_bytes(generated_torrent.as_ref()).unwrap(); - assert_eq!(parsed.info_hash, generated_parsed.info_hash); - assert_eq!(parsed.info, generated_parsed.info); - assert_eq!(parsed_trackers, get_trackers(&generated_parsed)); - } - - /// Test that magnet resolution respects the configured timeout and returns - /// an appropriate error when it expires. Uses a magnet with unreachable - /// initial peers so resolution will hang until the timeout. - #[tokio::test(flavor = "multi_thread")] - async fn test_magnet_resolution_timeout() { - use std::net::SocketAddr; - use std::str::FromStr; - use std::time::Instant; - - use crate::{AddTorrent, AddTorrentOptions, SessionOptions}; - - let tempdir = tempfile::TempDir::new().unwrap(); - let session = super::Session::new_with_opts( - tempdir.path().to_owned(), - SessionOptions { - disable_dht: true, - disable_trackers: true, - disable_local_service_discovery: true, - ..Default::default() - }, - ) - .await - .unwrap(); - - // A random info hash with no real peers -- resolution will hang. - let magnet = "magnet:?xt=urn:btih:0000000000000000000000000000000000000001"; - - let start = Instant::now(); - let result = session - .add_torrent( - AddTorrent::from_url(magnet), - Some(AddTorrentOptions { - // Set a short timeout so the test doesn't wait long. - magnet_resolution_timeout_secs: Some(2), - // Provide an unreachable peer so peer discovery doesn't - // immediately fail with "no known way to resolve peers". - initial_peers: Some(vec![SocketAddr::from_str("192.0.2.1:6881").unwrap()]), - ..Default::default() - }), - ) - .await; - - let elapsed = start.elapsed(); - assert!(result.is_err(), "expected a timeout error"); - let err_msg = format!("{:#}", result.err().unwrap()); - assert!( - err_msg.contains("magnet metadata resolution timed out"), - "error message should mention timeout, got: {err_msg}" - ); - // Verify the timeout was roughly respected (within a generous margin). - assert!( - elapsed.as_secs() < 10, - "should have timed out quickly, took {elapsed:?}" - ); - } - - /// Test that the concurrent init limit is configurable: setting it to a - /// custom value creates a semaphore with the correct number of permits. - #[tokio::test] - async fn test_concurrent_init_limit_configurable() { - use crate::SessionOptions; - - let tempdir = tempfile::TempDir::new().unwrap(); - - // Create session with a custom concurrent init limit. - let session = super::Session::new_with_opts( - tempdir.path().to_owned(), - SessionOptions { - disable_dht: true, - disable_trackers: true, - disable_local_service_discovery: true, - concurrent_init_limit: Some(5), - ..Default::default() - }, - ) - .await - .unwrap(); - - // The semaphore should have 5 available permits. - assert_eq!( - session.concurrent_initialize_semaphore.available_permits(), - 5, - "semaphore should have 5 permits" - ); - - // Default (no explicit limit) should give 3 permits. - let session_default = super::Session::new_with_opts( - tempdir.path().to_owned(), - SessionOptions { - disable_dht: true, - disable_trackers: true, - disable_local_service_discovery: true, - ..Default::default() - }, - ) - .await - .unwrap(); - - assert_eq!( - session_default - .concurrent_initialize_semaphore - .available_permits(), - 3, - "default semaphore should have 3 permits" - ); - } - - /// Test that when the init semaphore is full and a permit is dropped - /// (e.g. when a torrent finishes init or is deleted), the slot is freed - /// so that a waiting task can proceed. - #[tokio::test] - async fn test_init_semaphore_doesnt_deadlock() { - use std::sync::Arc; - use std::time::Duration; - - // Create a semaphore with 1 permit (simulating concurrent_init_limit = 1). - let sem = Arc::new(tokio::sync::Semaphore::new(1)); - - // Acquire the only permit. - let permit = sem.clone().acquire_owned().await.unwrap(); - - // Spawn a task that tries to acquire the semaphore -- it should block. - let sem2 = sem.clone(); - let acquired = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let acquired2 = acquired.clone(); - let handle = tokio::spawn(async move { - let _p = sem2.acquire().await.unwrap(); - acquired2.store(true, std::sync::atomic::Ordering::SeqCst); - }); - - // Give the spawned task a moment to start waiting. - tokio::time::sleep(Duration::from_millis(50)).await; - assert!( - !acquired.load(std::sync::atomic::Ordering::SeqCst), - "second acquire should be blocked" - ); - - // Drop the permit (simulating torrent deletion freeing the slot). - drop(permit); - - // The spawned task should now be able to acquire the permit. - tokio::time::timeout(Duration::from_secs(5), handle) - .await - .expect("task should complete after permit is freed") - .unwrap(); - - assert!( - acquired.load(std::sync::atomic::Ordering::SeqCst), - "second acquire should have succeeded after permit was dropped" - ); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/network.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/network.rs deleted file mode 100644 index 58c8293a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/network.rs +++ /dev/null @@ -1,150 +0,0 @@ -use std::{net::SocketAddr, sync::Arc, time::Duration}; - -use anyhow::{Context, bail}; -use futures::{StreamExt, TryFutureExt, stream::FuturesUnordered}; -use librqbit_utp::BindDevice; -use tokio::sync::Semaphore; -use tracing::{Instrument, debug, debug_span, trace, warn}; - -/// Maximum number of incoming connection handshakes that can run concurrently. -/// This prevents FD exhaustion during connection bursts. -const MAX_CONCURRENT_HANDSHAKES: usize = 64; - -use crate::{ - listen::Accept, - read_buf::ReadBuf, - stream_connect::ConnectionKind, - torrent_state::TorrentStateLive, - type_aliases::{BoxAsyncReadVectored, BoxAsyncWrite}, -}; - -use super::Session; -use super::types::CheckedIncomingConnection; - -impl Session { - pub(super) async fn check_incoming_connection( - self: Arc, - addr: SocketAddr, - kind: ConnectionKind, - mut reader: BoxAsyncReadVectored, - writer: BoxAsyncWrite, - ) -> anyhow::Result<(Arc, CheckedIncomingConnection)> { - let rwtimeout = self - .peer_opts - .read_write_timeout - .unwrap_or_else(|| Duration::from_secs(10)); - - let incoming_ip = addr.ip(); - if self.blocklist.has(incoming_ip) { - self.stats - .counters - .blocked_incoming - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - bail!("Incoming ip {incoming_ip} is in blocklist"); - } - if self.allowlist.as_ref().is_some_and(|l| !l.has(incoming_ip)) { - self.stats - .counters - .blocked_incoming - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - bail!("Incoming ip {incoming_ip} is not in allowlist"); - } - - let mut read_buf = ReadBuf::new(); - let h = read_buf - .read_handshake(&mut reader, rwtimeout) - .await - .context("error reading handshake")?; - trace!("received handshake from {addr}: {:?}", h); - - if h.peer_id == self.peer_id { - bail!("seems like we are connecting to ourselves, ignoring"); - } - - let (id, torrent) = self - .db - .read() - .get_by_info_hash(h.info_hash) - .map(|(id, t)| (id, t.clone())) - .with_context(|| format!("didn't find a matching torrent {:?}", h.info_hash))?; - - let live = torrent - .live_wait_initializing(Duration::from_secs(5)) - .await - .with_context(|| format!("torrent {id} is not live, ignoring connection"))?; - - Ok(( - live, - CheckedIncomingConnection { - addr, - reader, - writer, - kind, - handshake: h, - read_buf, - }, - )) - } - - pub(super) async fn task_listener(self: Arc, l: A) -> anyhow::Result<()> { - let mut futs = FuturesUnordered::new(); - let session = Arc::downgrade(&self); - let handshake_sem = Arc::new(Semaphore::new(MAX_CONCURRENT_HANDSHAKES)); - drop(self); - - loop { - tokio::select! { - r = l.accept() => { - match r { - Ok((addr, (read, write))) => { - let permit = match handshake_sem.clone().try_acquire_owned() { - Ok(p) => p, - Err(_) => { - debug!("max concurrent handshakes ({MAX_CONCURRENT_HANDSHAKES}) reached, dropping connection from {addr}"); - continue; - } - }; - trace!("accepted connection from {addr}"); - let session = session.upgrade().context("session is dead")?; - let span = debug_span!(parent: session.rs(), "incoming", addr=%addr); - futs.push( - async move { - let result = session.check_incoming_connection(addr, A::KIND, Box::new(read), Box::new(write)) - .map_err(|e| { - debug!("error checking incoming connection: {e:#}"); - e - }) - .await; - drop(permit); - result - } - .instrument(span) - ); - } - Err(e) => { - warn!("error accepting: {e:#}"); - // Whatever is the reason, ensure we are not stuck trying to - // accept indefinitely. - tokio::time::sleep(Duration::from_secs(10)).await; - continue - } - } - }, - Some(Ok((live, checked))) = futs.next(), if !futs.is_empty() => { - let (addr, kind) = (checked.addr, checked.kind); - if let Err(e) = live.add_incoming_peer(checked) { - warn!(?addr, ?kind, "error handing over incoming connection: {e:#}"); - } - }, - } - } - } - - pub(super) async fn task_upnp_port_forwarder( - port: u16, - bind_device: Option, - ) -> anyhow::Result<()> { - let pf = librtbit_upnp::UpnpPortForwarder::new(vec![port], None, bind_device)?; - pf.run_forever().await - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/peer_sources.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/peer_sources.rs deleted file mode 100644 index 0f76cf06..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/peer_sources.rs +++ /dev/null @@ -1,203 +0,0 @@ -use std::{net::SocketAddr, path::Path, sync::Arc, time::Duration}; - -use anyhow::{Context, bail}; -use dht::Id20; -use http::StatusCode; -use itertools::Itertools; -use tracing::{trace, warn}; - -use crate::{ - ApiError, CreateTorrentOptions, - api_error::WithStatus, - create_torrent, - create_torrent_file::CreateTorrentResult, - dht_utils::{ReadMetainfoResult, read_metainfo_from_peer_receiver}, - peer_connection::PeerConnectionOptions, - torrent_state::{ManagedTorrentHandle, TorrentMetadata}, - type_aliases::PeerStream, -}; -use tracker_comms::TrackerComms; - -use super::Session; -use super::helpers::merge_two_optional_streams; -use super::types::{ - AddTorrent, AddTorrentOptions, PeerRxTorrentInfo, ResolveMagnetResult, - torrent_file_from_info_bytes, -}; - -use crate::ManagedTorrent; - -impl Session { - pub fn make_peer_rx_managed_torrent( - self: &Arc, - t: &Arc, - announce: bool, - ) -> Option { - let is_private = t.with_metadata(|m| m.info.info().private).unwrap_or(false); - self.make_peer_rx( - t.info_hash(), - t.shared().trackers.iter().cloned().collect(), - announce, - t.shared().options.force_tracker_interval, - t.shared().options.initial_peers.clone(), - is_private, - ) - } - - // Get a peer stream from both DHT and trackers. - pub(crate) fn make_peer_rx( - self: &Arc, - info_hash: Id20, - mut trackers: Vec, - announce: bool, - force_tracker_interval: Option, - initial_peers: Vec, - is_private: bool, - ) -> Option { - let dht_rx = if is_private { - None - } else { - self.dht.as_ref().map(|dht| { - dht.get_peers(info_hash, if announce { self.announce_port } else { None }) - }) - }; - - let lsd_rx = if is_private { - None - } else { - self.lsd.as_ref().map(|lsd| { - lsd.announce(info_hash, if announce { self.announce_port } else { None }) - }) - }; - - if self.disable_trackers { - trackers.clear(); - } - - if is_private && trackers.len() > 1 { - warn!( - ?info_hash, - "private trackers are not fully implemented, so using only the first tracker" - ); - trackers.truncate(1); - } else if !self.disable_trackers { - trackers.extend(self.trackers.iter().cloned()); - } - - let tracker_rx_stats = PeerRxTorrentInfo { - info_hash, - session: self.clone(), - }; - let tracker_rx = TrackerComms::start( - info_hash, - self.peer_id, - trackers.into_iter().collect(), - Box::new(tracker_rx_stats), - force_tracker_interval, - self.announce_port().unwrap_or(4240), - self.reqwest_client.clone(), - self.udp_tracker_client.clone(), - ); - - let initial_peers_rx = if initial_peers.is_empty() { - None - } else { - Some(futures::stream::iter(initial_peers)) - }; - merge_two_optional_streams( - merge_two_optional_streams( - merge_two_optional_streams(dht_rx, tracker_rx), - initial_peers_rx, - ), - lsd_rx, - ) - } - - pub(super) async fn resolve_magnet( - self: &Arc, - info_hash: Id20, - peer_rx: PeerStream, - trackers: &[url::Url], - peer_opts: Option, - ) -> anyhow::Result { - match read_metainfo_from_peer_receiver( - self.peer_id, - info_hash, - Default::default(), - peer_rx, - Some(self.merge_peer_opts(peer_opts)), - self.connector.clone(), - ) - .await - { - ReadMetainfoResult::Found { - info, - info_bytes, - rx, - seen, - } => { - trace!(?info, "received result from DHT"); - let info = info.validate()?; - Ok(ResolveMagnetResult { - metadata: TorrentMetadata::new( - info, - torrent_file_from_info_bytes(info_bytes.as_ref(), trackers)?, - info_bytes.0, - )?, - peer_rx: rx, - seen_peers: { - let seen = seen.into_iter().collect_vec(); - for peer in &seen { - trace!(?peer, "seen") - } - seen - }, - }) - } - ReadMetainfoResult::ChannelClosed { .. } => { - bail!("input address stream exhausted, no way to discover torrent metainfo") - } - } - } - - pub async fn create_and_serve_torrent( - self: &Arc, - path: &Path, - opts: CreateTorrentOptions<'_>, - ) -> Result<(CreateTorrentResult, ManagedTorrentHandle), ApiError> { - if !path.exists() { - return Err(ApiError::from(( - StatusCode::BAD_REQUEST, - "path doesn't exist", - ))); - } - - let torrent = create_torrent(path, opts, &self.spawner) - .await - .with_status(StatusCode::BAD_REQUEST)?; - - let bytes = torrent.as_bytes()?; - - let handle = self - .add_torrent( - AddTorrent::TorrentFileBytes(bytes.clone()), - Some(AddTorrentOptions { - paused: false, - overwrite: true, - output_folder: Some( - torrent - .output_folder - .to_str() - .context("invalid utf-8")? - .to_owned(), - ), - ..Default::default() - }), - ) - .await? - .into_handle() - .context("error adding to session")?; - - Ok((torrent, handle)) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/types.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/types.rs deleted file mode 100644 index 07a0b533..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session/types.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::{ - borrow::Cow, - collections::HashMap, - io::Read, - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; - -use anyhow::Context; -use buffers::{ByteBuf, ByteBufOwned}; -use bytes::Bytes; -use clone_to_owned::CloneToOwned; -use dht::Id20; -use librtbit_core::{ - directories::get_configuration_directory, - magnet::Magnet, - torrent_metainfo::{TorrentMetaV1Owned, ValidatedTorrentMetaV1Info}, -}; -use serde::{Deserialize, Serialize}; -use tracing::trace; - -use crate::{ - limits::LimitsConfig, - listen::ListenerOptions, - peer_connection::PeerConnectionOptions, - read_buf::ReadBuf, - storage::BoxStorageFactory, - stream_connect::{ConnectionKind, ConnectionOptions}, - torrent_state::{ManagedTorrentHandle, TorrentMetadata}, - type_aliases::{BoxAsyncReadVectored, BoxAsyncWrite, PeerStream}, -}; -use peer_binary_protocol::Handshake; - -use super::{Session, TorrentId}; - -pub(crate) struct ParsedTorrentFile { - pub meta: TorrentMetaV1Owned, - pub torrent_bytes: Bytes, -} - -pub(crate) fn torrent_from_bytes(bytes: Bytes) -> anyhow::Result { - trace!( - "all fields in torrent: {:#?}", - bencode::dyn_from_bytes::(&bytes) - ); - let parsed = librtbit_core::torrent_metainfo::torrent_from_bytes(&bytes)?; - Ok(ParsedTorrentFile { - meta: parsed.clone_to_owned(Some(&bytes)), - torrent_bytes: bytes, - }) -} - -#[derive(Default)] -pub struct SessionDatabase { - pub(crate) torrents: HashMap, - /// Reverse index for O(1) lookup by info_hash (used in incoming peer handshakes). - pub(crate) info_hash_to_id: HashMap, -} - -impl SessionDatabase { - pub(crate) fn add_torrent(&mut self, torrent: ManagedTorrentHandle, id: TorrentId) { - let info_hash = torrent.info_hash(); - self.info_hash_to_id.insert(info_hash, id); - self.torrents.insert(id, torrent); - } - - pub(crate) fn remove_torrent(&mut self, id: &TorrentId) -> Option { - let removed = self.torrents.remove(id); - if let Some(ref handle) = removed { - self.info_hash_to_id.remove(&handle.info_hash()); - } - removed - } - - /// O(1) lookup by info_hash. - pub(crate) fn get_by_info_hash( - &self, - info_hash: Id20, - ) -> Option<(TorrentId, &ManagedTorrentHandle)> { - let id = self.info_hash_to_id.get(&info_hash)?; - let handle = self.torrents.get(id)?; - Some((*id, handle)) - } -} - -/// Options for adding new torrents to the session. -// -// Serialize/deserialize is for Tauri. -#[derive(Default, Serialize, Deserialize)] -pub struct AddTorrentOptions { - /// Start in paused state. - #[serde(default)] - pub paused: bool, - /// A regex to only download files matching it. - pub only_files_regex: Option, - /// An explicit list of file IDs to download. - /// To see the file indices, run with "list_only". - pub only_files: Option>, - /// Allow writing on top of existing files, including when resuming a torrent. - /// You probably want to set it, however for safety it's not default. - /// - /// Even when all the torrent pieces have been written, `overwrite` needs to - /// be enabled in order to resume/seed the torrent. - #[serde(default)] - pub overwrite: bool, - /// Only list the files in the torrent without starting it. - #[serde(default)] - pub list_only: bool, - /// The output folder for the torrent. If not set, the session's default one will be used. - pub output_folder: Option, - /// Sub-folder within session's default output folder. Will error if "output_folder" if also set. - /// By default, multi-torrent files are downloaded to a sub-folder. - pub sub_folder: Option, - /// Peer connection options, timeouts etc. If not set, session's defaults will be used. - pub peer_opts: Option, - - /// Force a refresh interval for polling trackers. - pub force_tracker_interval: Option, - - #[serde(default)] - pub disable_trackers: bool, - - #[serde(default)] - pub ratelimits: LimitsConfig, - - /// Initial peers to start of with. - pub initial_peers: Option>, - - /// Max concurrent connected peers. - pub peer_limit: Option, - - /// This is used to restore the session from serialized state. - pub preferred_id: Option, - - #[serde(skip)] - pub storage_factory: Option, - - // Custom trackers - pub trackers: Option>, - - /// Category to assign to this torrent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub category: Option, - - /// Timeout for magnet link metadata resolution, in seconds. - /// If not set, defaults to 120 seconds. Set to 0 to disable the timeout. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub magnet_resolution_timeout_secs: Option, -} - -pub struct ListOnlyResponse { - pub info_hash: Id20, - pub info: ValidatedTorrentMetaV1Info, - pub only_files: Option>, - pub output_folder: PathBuf, - pub seen_peers: Vec, - pub torrent_bytes: Bytes, -} - -#[allow(clippy::large_enum_variant)] -pub enum AddTorrentResponse { - AlreadyManaged(TorrentId, ManagedTorrentHandle), - ListOnly(ListOnlyResponse), - Added(TorrentId, ManagedTorrentHandle), -} - -impl AddTorrentResponse { - pub fn into_handle(self) -> Option { - match self { - Self::AlreadyManaged(_, handle) => Some(handle), - Self::ListOnly(_) => None, - Self::Added(_, handle) => Some(handle), - } - } -} - -pub fn read_local_file_including_stdin(filename: &str) -> anyhow::Result> { - let mut buf = Vec::new(); - if filename == "-" { - std::io::stdin() - .read_to_end(&mut buf) - .context("error reading stdin")?; - } else { - std::fs::File::open(filename) - .context("error opening")? - .read_to_end(&mut buf) - .context("error reading")?; - } - Ok(buf) -} - -pub enum AddTorrent<'a> { - Url(Cow<'a, str>), - TorrentFileBytes(Bytes), -} - -impl<'a> AddTorrent<'a> { - // Don't call this from HTTP API. - #[inline(never)] - pub fn from_cli_argument(path: &'a str) -> anyhow::Result { - if super::SUPPORTED_SCHEMES.iter().any(|s| path.starts_with(s)) { - return Ok(Self::Url(Cow::Borrowed(path))); - } - if path.len() == 40 && !Path::new(path).exists() && Magnet::parse(path).is_ok() { - return Ok(Self::Url(Cow::Borrowed(path))); - } - Self::from_local_filename(path) - } - - pub fn from_url(url: impl Into>) -> Self { - Self::Url(url.into()) - } - - pub fn from_bytes(bytes: impl Into) -> Self { - Self::TorrentFileBytes(bytes.into()) - } - - // Don't call this from HTTP API. - #[inline(never)] - pub fn from_local_filename(filename: &str) -> anyhow::Result { - let file = read_local_file_including_stdin(filename) - .with_context(|| format!("error reading local file {filename:?}"))?; - Ok(Self::TorrentFileBytes(file.into())) - } - - pub fn into_bytes(self) -> Bytes { - match self { - Self::Url(s) => s.into_owned().into_bytes().into(), - Self::TorrentFileBytes(b) => b, - } - } -} - -pub enum SessionPersistenceConfig { - /// The filename for persistence. By default uses an OS-specific folder. - Json { folder: Option }, - #[cfg(feature = "postgres")] - Postgres { connection_string: String }, -} - -impl SessionPersistenceConfig { - pub fn default_json_persistence_folder() -> anyhow::Result { - let dir = get_configuration_directory("session")?; - Ok(dir.data_dir().to_owned()) - } -} - -#[derive(Default)] -pub struct SessionOptions { - /// Turn on to disable DHT. - pub disable_dht: bool, - /// Turn on to disable DHT persistence. By default it will re-use stored DHT - /// configuration, including the port it listens on. - pub disable_dht_persistence: bool, - /// Pass in to configure DHT persistence filename. This can be used to run multiple - /// librtbit instances at a time. - pub dht_config: Option, - /// A list o DHT bootstrap nodes as strings of the form host:port or ip:port - pub dht_bootstrap_addrs: Option>, - - /// What network device to bind to for DHT, BT-UDP, BT-TCP, trackers and LSD. - /// On OSX will use IP(V6)_BOUND_IF, on Linux will use SO_BINDTODEVICE. - pub bind_device_name: Option, - - /// Disable tracker communication - pub disable_trackers: bool, - - /// Enable fastresume, to restore state quickly after restart. - pub fastresume: bool, - - /// Controls the intensity of probabilistic piece validation during fast resume. - /// - `None`: uses the built-in default (denominator capped at 50). - /// - `Some(0)`: skip probabilistic validation entirely (only per-file mandatory checks). - /// - `Some(n)` where n > 0: cap the denominator at n. Higher = more pieces checked. - pub fastresume_validation_denom: Option, - - /// Turn on to dump session contents into a file periodically, so that on next start - /// all remembered torrents will continue where they left off. - pub persistence: Option, - - /// The peer ID to use. If not specified, a random one will be generated. - pub peer_id: Option, - - /// Options for listening on TCP and/or uTP for incoming connections. - pub listen: Option, - /// Options for connecting to peers (for outgiong connections). - pub connect: Option, - - pub default_storage_factory: Option, - - pub cancellation_token: Option, - - /// how many concurrent torrent initializations can happen - pub concurrent_init_limit: Option, - - /// How many blocking threads does the tokio runtime have. - /// Will limit blocking work to that number to avoid starving the runtime. - pub runtime_worker_threads: Option, - - /// the root span to use. If not set will be None. - pub root_span: Option, - - pub ratelimits: LimitsConfig, - - pub blocklist_url: Option, - pub allowlist_url: Option, - - // The list of tracker URLs to always use for each torrent. - pub trackers: std::collections::HashSet, - - /// Default peer limit per torrent. - pub peer_limit: Option, - - #[cfg(feature = "disable-upload")] - pub disable_upload: bool, - - /// Disable LSD multicast - pub disable_local_service_discovery: bool, - - /// Force IPv4 only. - pub ipv4_only: bool, - - /// A folder to move completed torrents to. If set, when a torrent finishes - /// downloading, its files will be moved from the output folder to this folder. - pub completed_folder: Option, -} - -pub(crate) fn torrent_file_from_info_bytes( - info_bytes: &[u8], - trackers: &[url::Url], -) -> anyhow::Result { - #[derive(Serialize)] - struct Tmp<'a> { - announce: &'a str, - #[serde(rename = "announce-list")] - announce_list: &'a [&'a [url::Url]], - info: bencode::raw_value::RawValue<&'a [u8]>, - } - - let mut w = Vec::new(); - let v = Tmp { - info: bencode::raw_value::RawValue(info_bytes), - announce: trackers.first().map(|s| s.as_str()).unwrap_or(""), - announce_list: &[trackers], - }; - bencode::bencode_serialize_to_writer(&v, &mut w)?; - Ok(w.into()) -} - -pub(crate) struct CheckedIncomingConnection { - pub kind: ConnectionKind, - pub addr: SocketAddr, - pub reader: BoxAsyncReadVectored, - pub writer: BoxAsyncWrite, - pub read_buf: ReadBuf, - pub handshake: Handshake, -} - -pub(super) struct InternalAddResult { - pub info_hash: Id20, - pub metadata: Option, - pub trackers: Vec, - pub name: Option, - pub web_seed_urls: Vec, -} - -pub(crate) struct ResolveMagnetResult { - pub metadata: TorrentMetadata, - pub peer_rx: PeerStream, - pub seen_peers: Vec, -} - -// An adapter for converting stats into the format that tracker_comms accepts. -pub(super) struct PeerRxTorrentInfo { - pub info_hash: Id20, - pub session: Arc, -} - -impl tracker_comms::TorrentStatsProvider for PeerRxTorrentInfo { - fn get(&self) -> tracker_comms::TrackerCommsStats { - let mt = self.session.with_torrents(|torrents| { - for (_, mt) in torrents { - if mt.info_hash() == self.info_hash { - return Some(mt.clone()); - } - } - None - }); - let mt = match mt { - Some(mt) => mt, - None => { - trace!(info_hash=?self.info_hash, "can't find torrent in the session, using default stats"); - return Default::default(); - } - }; - let stats = mt.stats(); - - use crate::torrent_state::stats::TorrentStatsState as TS; - use tracker_comms::TrackerCommsStatsState as S; - - tracker_comms::TrackerCommsStats { - downloaded_bytes: stats.progress_bytes, - total_bytes: stats.total_bytes, - uploaded_bytes: stats.uploaded_bytes, - torrent_state: match stats.state { - TS::Initializing => S::Initializing, - TS::Live => S::Live, - TS::Paused => S::Paused, - TS::Error => S::None, - }, - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/json.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/json.rs deleted file mode 100644 index df63637c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/json.rs +++ /dev/null @@ -1,393 +0,0 @@ -use std::{any::TypeId, collections::HashMap, path::PathBuf}; - -use crate::{ - api::TorrentIdOrHash, - bitv::{BitV, DiskBackedBitV}, - bitv_factory::BitVFactory, - category::TorrentCategory, - session::TorrentId, - spawn_utils::BlockingSpawner, - storage::filesystem::FilesystemStorageFactory, - torrent_state::ManagedTorrentHandle, - type_aliases::BF, -}; -use anyhow::{Context, bail}; -use async_trait::async_trait; -use futures::{StreamExt, stream::BoxStream}; -use itertools::Itertools; -use librtbit_core::Id20; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tracing::{debug, trace, warn}; - -use super::{SerializedTorrent, SessionPersistenceStore}; - -#[derive(Serialize, Deserialize, Default)] -struct SerializedSessionDatabase { - torrents: HashMap, -} - -pub struct JsonSessionPersistenceStore { - output_folder: PathBuf, - db_filename: PathBuf, - db_content: tokio::sync::RwLock, - spawner: BlockingSpawner, -} - -impl std::fmt::Debug for JsonSessionPersistenceStore { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "JSON database: {:?}", self.output_folder) - } -} - -impl JsonSessionPersistenceStore { - pub async fn new(output_folder: PathBuf, spawner: BlockingSpawner) -> anyhow::Result { - let db_filename = output_folder.join("session.json"); - tokio::fs::create_dir_all(&output_folder) - .await - .with_context(|| { - format!("couldn't create directory {output_folder:?} for session storage") - })?; - - let db = match tokio::fs::File::open(&db_filename).await { - Ok(f) => { - let mut buf = Vec::new(); - let mut rdr = tokio::io::BufReader::new(f); - rdr.read_to_end(&mut buf).await?; - - serde_json::from_reader(&buf[..]).context("error deserializing session database")? - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(), - Err(e) => { - return Err(e).context(format!("error opening session file {db_filename:?}")); - } - }; - - Ok(Self { - db_filename, - output_folder, - db_content: tokio::sync::RwLock::new(db), - spawner, - }) - } - - async fn to_hash(&self, id: TorrentIdOrHash) -> anyhow::Result { - match id { - TorrentIdOrHash::Id(id) => self - .db_content - .read() - .await - .torrents - .get(&id) - .map(|v| *v.info_hash()) - .context("not found"), - TorrentIdOrHash::Hash(h) => Ok(h), - } - } - - async fn flush(&self) -> anyhow::Result<()> { - // we don't need the write lock technically, but we need to stop concurrent modifications - let db_content = self.db_content.write().await; - let tmp_filename = format!("{}.tmp", self.db_filename.to_str().unwrap()); - let mut tmp = tokio::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {tmp_filename:?}"))?; - trace!(?tmp_filename, "opened temp file"); - - let mut buf = Vec::new(); - serde_json::to_writer(&mut buf, &*db_content).context("error serializing")?; - - trace!(?tmp_filename, "serialized DB as JSON"); - tmp.write_all(&buf) - .await - .with_context(|| format!("error writing {tmp_filename:?}"))?; - trace!(?tmp_filename, "wrote to temp file"); - - tokio::fs::rename(&tmp_filename, &self.db_filename) - .await - .context("error renaming persistence file")?; - trace!(filename=?self.db_filename, "wrote persistence"); - Ok(()) - } - - fn torrent_bytes_filename(&self, info_hash: &Id20) -> PathBuf { - self.output_folder.join(format!("{info_hash:?}.torrent")) - } - - fn bitv_filename(&self, info_hash: &Id20) -> PathBuf { - self.output_folder.join(format!("{info_hash:?}.bitv")) - } - - fn categories_filename(&self) -> PathBuf { - self.output_folder.join("categories.json") - } - - async fn update_db( - &self, - id: TorrentId, - torrent: &ManagedTorrentHandle, - write_torrent_file: bool, - ) -> anyhow::Result<()> { - if !torrent - .shared - .storage_factory - .is_type_id(TypeId::of::()) - { - bail!("storages other than FilesystemStorageFactory are not supported"); - } - - let st = SerializedTorrent { - trackers: torrent - .shared() - .trackers - .iter() - .map(|u| u.to_string()) - .collect(), - info_hash: torrent.info_hash(), - // we don't serialize this here, but to a file instead. - torrent_bytes: Default::default(), - only_files: torrent.only_files().clone(), - is_paused: torrent.is_paused(), - output_folder: torrent.shared().options.output_folder.clone(), - category: torrent.shared().category.read().clone(), - }; - - let torrent_bytes = torrent - .metadata - .load() - .as_ref() - .map(|i| i.torrent_bytes.clone()) - .unwrap_or_default(); - - if write_torrent_file && !torrent_bytes.is_empty() { - let torrent_bytes_file = self.torrent_bytes_filename(&torrent.info_hash()); - match tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&torrent_bytes_file) - .await - { - Ok(mut f) => { - if let Err(e) = f.write_all(&torrent_bytes).await { - warn!(error=?e, file=?torrent_bytes_file, "error writing torrent bytes") - } - } - Err(e) => { - warn!(error=?e, file=?torrent_bytes_file, "error opening torrent bytes file") - } - } - } - - self.db_content.write().await.torrents.insert(id, st); - self.flush().await?; - - Ok(()) - } -} - -#[async_trait::async_trait] -impl BitVFactory for JsonSessionPersistenceStore { - async fn load(&self, id: TorrentIdOrHash) -> anyhow::Result>> { - let h = self.to_hash(id).await?; - let filename = self.bitv_filename(&h); - match DiskBackedBitV::new(filename.clone(), self.spawner.clone()).await { - Ok(bitv) => Ok(Some(bitv.into_dyn())), - Err(e) => { - if let Some(e) = e.downcast_ref::() - && matches!(e.kind(), std::io::ErrorKind::NotFound) - { - return Ok(None); - } - // CRC mismatch or other corruption: log warning and return None - // to trigger a full integrity check instead of aborting - if format!("{e:#}").contains("CRC32 mismatch") { - warn!(?filename, "bitv CRC32 mismatch, will do full check"); - return Ok(None); - } - return Err(e); - } - } - } - - async fn clear(&self, id: TorrentIdOrHash) -> anyhow::Result<()> { - let h = self.to_hash(id).await?; - let filename = self.bitv_filename(&h); - tokio::fs::remove_file(&filename) - .await - .with_context(|| format!("error removing {filename:?}")) - } - - async fn store_initial_check( - &self, - id: TorrentIdOrHash, - b: BF, - ) -> anyhow::Result> { - let h = self.to_hash(id).await?; - let filename = self.bitv_filename(&h); - let tmp_filename = format!("{}.tmp", filename.to_str().context("bug")?); - let mut dst = tokio::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {filename:?}"))?; - let data = crate::bitv::bitv_with_header(b.as_raw_slice()); - dst.write_all(&data) - .await - .context("error writing bitslice to {filename:?}")?; - tokio::fs::rename(&tmp_filename, &filename) - .await - .with_context(|| format!("error renaming {tmp_filename:?} to {filename:?}"))?; - trace!(?filename, "stored initial check bitfield"); - Ok(DiskBackedBitV::new(filename.clone(), self.spawner.clone()) - .await - .with_context(|| format!("error constructing MmapBitV from file {filename:?}"))? - .into_dyn()) - } -} - -#[async_trait] -impl SessionPersistenceStore for JsonSessionPersistenceStore { - async fn next_id(&self) -> anyhow::Result { - Ok(self - .db_content - .read() - .await - .torrents - .keys() - .copied() - .max() - .map(|max| max + 1) - .unwrap_or(0)) - } - - async fn delete(&self, id: TorrentId) -> anyhow::Result<()> { - debug!(?id, "attempting to delete"); - // BIG NOTE: DO NOT inline this variable. Otherwise Rust doesn't drop the lock and it deadlocks - // when calling flush - because let bindings prolong the duration. - let removed = self.db_content.write().await.torrents.remove(&id); - if let Some(t) = removed { - debug!(?id, "deleted from in-memory db, flushing"); - self.flush().await?; - for tf in [ - self.torrent_bytes_filename(&t.info_hash), - self.bitv_filename(&t.info_hash), - ] { - if let Err(e) = tokio::fs::remove_file(&tf).await { - warn!(error=?e, filename=?tf, "error removing"); - } else { - debug!(filename=?tf, "removed"); - } - } - } else { - bail!("error deleting: didn't find torrent id={id}") - } - - Ok(()) - } - - async fn get(&self, id: TorrentId) -> anyhow::Result { - let mut st = self - .db_content - .read() - .await - .torrents - .get(&id) - .cloned() - .context("no torrent found")?; - let mut buf = Vec::new(); - let torrent_bytes_filename = self.torrent_bytes_filename(&st.info_hash); - let mut torrent_bytes_file = match tokio::fs::File::open(&torrent_bytes_filename).await { - Ok(f) => f, - Err(e) => { - warn!(error=?e, filename=?torrent_bytes_filename, "error opening torrent bytes file"); - return Ok(st); - } - }; - if let Err(e) = torrent_bytes_file.read_to_end(&mut buf).await { - warn!(error=?e, filename=?torrent_bytes_filename, "error reading torrent bytes file"); - } else { - st.torrent_bytes = buf.into(); - } - return Ok(st); - } - - async fn stream_all( - &self, - ) -> anyhow::Result>> { - let all_ids = self - .db_content - .read() - .await - .torrents - .keys() - .copied() - .collect_vec(); - Ok(futures::stream::iter(all_ids) - .then(move |id| async move { self.get(id).await.map(move |st| (id, st)) }) - .boxed()) - } - - async fn store(&self, id: TorrentId, torrent: &ManagedTorrentHandle) -> anyhow::Result<()> { - self.update_db(id, torrent, true).await - } - - async fn update_metadata( - &self, - id: TorrentId, - torrent: &ManagedTorrentHandle, - ) -> anyhow::Result<()> { - self.update_db(id, torrent, false).await - } - - async fn load_categories(&self) -> anyhow::Result> { - let filename = self.categories_filename(); - match tokio::fs::File::open(&filename).await { - Ok(f) => { - let mut buf = Vec::new(); - let mut rdr = tokio::io::BufReader::new(f); - rdr.read_to_end(&mut buf).await?; - let categories: HashMap = - serde_json::from_reader(&buf[..]).context("error deserializing categories")?; - Ok(categories) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()), - Err(e) => Err(e).context(format!("error opening categories file {filename:?}")), - } - } - - async fn store_categories( - &self, - categories: &HashMap, - ) -> anyhow::Result<()> { - let filename = self.categories_filename(); - let tmp_filename = format!("{}.tmp", filename.to_str().unwrap()); - let mut tmp = tokio::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {tmp_filename:?}"))?; - - let mut buf = Vec::new(); - serde_json::to_writer_pretty(&mut buf, categories) - .context("error serializing categories")?; - - tmp.write_all(&buf) - .await - .with_context(|| format!("error writing {tmp_filename:?}"))?; - - tokio::fs::rename(&tmp_filename, &filename) - .await - .context("error renaming categories file")?; - trace!(filename=?filename, "wrote categories"); - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/mod.rs deleted file mode 100644 index e92aa444..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/mod.rs +++ /dev/null @@ -1,124 +0,0 @@ -pub mod json; -#[cfg(feature = "postgres")] -pub mod postgres; - -use std::{collections::HashMap, collections::HashSet, path::PathBuf}; - -use anyhow::Context; -use async_trait::async_trait; -use bytes::Bytes; -use futures::stream::BoxStream; -use librtbit_core::Id20; -use librtbit_core::magnet::Magnet; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::{ - AddTorrent, AddTorrentOptions, bitv_factory::BitVFactory, category::TorrentCategory, - session::TorrentId, torrent_state::ManagedTorrentHandle, -}; - -#[derive(Serialize, Deserialize, Clone)] -pub struct SerializedTorrent { - #[serde( - serialize_with = "serialize_info_hash", - deserialize_with = "deserialize_info_hash" - )] - info_hash: Id20, - #[serde(skip)] - torrent_bytes: Bytes, - trackers: HashSet, - output_folder: PathBuf, - only_files: Option>, - is_paused: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - category: Option, -} - -impl SerializedTorrent { - pub fn info_hash(&self) -> &Id20 { - &self.info_hash - } - - #[allow(dead_code)] - pub fn category(&self) -> Option<&str> { - self.category.as_deref() - } - - #[allow(dead_code)] - pub fn set_category(&mut self, category: Option) { - self.category = category; - } - - pub fn into_add_torrent(self) -> anyhow::Result<(AddTorrent<'static>, AddTorrentOptions)> { - let category = self.category.clone(); - let add_torrent = if !self.torrent_bytes.is_empty() { - AddTorrent::TorrentFileBytes(self.torrent_bytes) - } else { - let magnet = Magnet::from_id20( - self.info_hash, - self.trackers.into_iter().collect(), - self.only_files.clone(), - ) - .to_string(); - AddTorrent::from_url(magnet) - }; - - let opts = AddTorrentOptions { - paused: self.is_paused, - output_folder: Some( - self.output_folder - .to_str() - .context("broken path")? - .to_owned(), - ), - only_files: self.only_files, - overwrite: true, - category, - ..Default::default() - }; - - Ok((add_torrent, opts)) - } -} - -// TODO: make this info_hash first, ID-second. -#[async_trait] -pub trait SessionPersistenceStore: core::fmt::Debug + Send + Sync + BitVFactory { - async fn next_id(&self) -> anyhow::Result; - async fn store(&self, id: TorrentId, torrent: &ManagedTorrentHandle) -> anyhow::Result<()>; - async fn delete(&self, id: TorrentId) -> anyhow::Result<()>; - async fn get(&self, id: TorrentId) -> anyhow::Result; - async fn update_metadata( - &self, - id: TorrentId, - torrent: &ManagedTorrentHandle, - ) -> anyhow::Result<()>; - async fn stream_all( - &self, - ) -> anyhow::Result>>; - - async fn load_categories(&self) -> anyhow::Result> { - Ok(HashMap::new()) - } - - async fn store_categories( - &self, - _categories: &HashMap, - ) -> anyhow::Result<()> { - Ok(()) - } -} - -fn serialize_info_hash(id: &Id20, serializer: S) -> Result -where - S: Serializer, -{ - id.as_string().serialize(serializer) -} - -fn deserialize_info_hash<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - Id20::deserialize(deserializer) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/postgres.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/postgres.rs deleted file mode 100644 index 3a57d0cb..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_persistence/postgres.rs +++ /dev/null @@ -1,349 +0,0 @@ -use std::path::PathBuf; - -use crate::{ - api::TorrentIdOrHash, bitv::BitV, bitv_factory::BitVFactory, session::TorrentId, - torrent_state::ManagedTorrentHandle, type_aliases::BF, -}; -use anyhow::Context; -use futures::{StreamExt, stream::BoxStream}; -use librtbit_core::{Id20, spawn_utils::spawn}; -use sqlx::{Pool, Postgres}; -use tracing::debug_span; - -use super::{SerializedTorrent, SessionPersistenceStore}; - -#[derive(Debug)] -pub struct PostgresSessionStorage { - pool: Pool, -} - -#[derive(sqlx::FromRow)] -struct TorrentsTableRecord { - id: i32, - info_hash: Vec, - torrent_bytes: Vec, - trackers: Vec, - output_folder: String, - only_files: Option>, - is_paused: bool, -} - -impl TorrentsTableRecord { - fn into_serialized_torrent(self) -> Option<(TorrentId, SerializedTorrent)> { - Some(( - self.id as TorrentId, - SerializedTorrent { - info_hash: Id20::from_bytes(&self.info_hash).ok()?, - torrent_bytes: self.torrent_bytes.into(), - trackers: self.trackers.into_iter().collect(), - output_folder: PathBuf::from(self.output_folder), - only_files: self - .only_files - .map(|v| v.into_iter().map(|v| v as usize).collect()), - is_paused: self.is_paused, - category: None, - }, - )) - } -} - -impl PostgresSessionStorage { - pub async fn new(connection_string: &str) -> anyhow::Result { - use sqlx::postgres::PgPoolOptions; - - let pool = PgPoolOptions::new() - .max_connections(1) - .connect(connection_string) - .await?; - - macro_rules! exec { - ($q:expr) => { - sqlx::query($q) - .execute(&pool) - .await - .context($q) - .context("error running query")?; - }; - } - - exec!("CREATE SEQUENCE IF NOT EXISTS torrents_id AS integer;"); - - exec!( - "CREATE TABLE IF NOT EXISTS torrents ( - id INTEGER PRIMARY KEY DEFAULT nextval('torrents_id'), - info_hash BYTEA NOT NULL, - torrent_bytes BYTEA NOT NULL, - trackers TEXT[] NOT NULL, - output_folder TEXT NOT NULL, - only_files INTEGER[], - is_paused BOOLEAN NOT NULL - )" - ); - - exec!("ALTER TABLE torrents ADD COLUMN IF NOT EXISTS have_bitfield BYTEA"); - - Ok(Self { pool }) - } -} - -#[async_trait::async_trait] -impl SessionPersistenceStore for PostgresSessionStorage { - async fn next_id(&self) -> anyhow::Result { - let (id,): (i32,) = sqlx::query_as("SELECT nextval('torrents_id')::int") - .fetch_one(&self.pool) - .await - .context("error executing SELECT nextval")?; - Ok(id as usize) - } - - async fn store(&self, id: TorrentId, torrent: &ManagedTorrentHandle) -> anyhow::Result<()> { - let torrent_bytes = torrent - .metadata - .load() - .as_ref() - .map(|i| i.torrent_bytes.clone()) - .unwrap_or_default(); - let q = "INSERT INTO torrents (id, info_hash, torrent_bytes, trackers, output_folder, only_files, is_paused) - VALUES($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT(id) DO NOTHING"; - sqlx::query(q) - .bind::(id.try_into()?) - .bind(&torrent.info_hash().0[..]) - .bind(torrent_bytes.as_ref()) - .bind( - torrent - .shared() - .trackers - .iter() - .map(|t| t.to_string()) - .collect::>(), - ) - .bind( - torrent - .shared() - .options - .output_folder - .to_str() - .context("output_folder")? - .to_owned(), - ) - .bind(torrent.only_files().map(|o| { - o.into_iter() - .filter_map(|o| o.try_into().ok()) - .collect::>() - })) - .bind(torrent.is_paused()) - .execute(&self.pool) - .await - .context("error executing INSERT INTO torrents")?; - Ok(()) - } - - async fn delete(&self, id: TorrentId) -> anyhow::Result<()> { - sqlx::query("DELETE FROM torrents WHERE id = $1") - .bind::(id.try_into()?) - .execute(&self.pool) - .await - .context("error executing DELETE FROM torrents")?; - Ok(()) - } - - async fn get(&self, id: TorrentId) -> anyhow::Result { - let row = sqlx::query_as::<_, TorrentsTableRecord>("SELECT * FROM torrents WHERE id = ?") - .bind::(id.try_into()?) - .fetch_one(&self.pool) - .await - .context("error executing SELECT * FROM torrents")?; - row.into_serialized_torrent() - .context("bug") - .map(|(_, st)| st) - } - - async fn update_metadata( - &self, - id: TorrentId, - torrent: &ManagedTorrentHandle, - ) -> anyhow::Result<()> { - sqlx::query("UPDATE torrents SET only_files = $1, is_paused = $2 WHERE id = $3") - .bind(torrent.only_files().map(|v| { - v.into_iter() - .filter_map(|f| f.try_into().ok()) - .collect::>() - })) - .bind(torrent.is_paused()) - .bind::(id.try_into()?) - .execute(&self.pool) - .await - .context("error executing UPDATE torrents")?; - Ok(()) - } - - async fn stream_all( - &self, - ) -> anyhow::Result>> { - let torrents = sqlx::query_as::<_, TorrentsTableRecord>("SELECT * FROM torrents") - .fetch_all(&self.pool) - .await - .context("error executing SELECT * FROM torrents")? - .into_iter() - .filter_map(TorrentsTableRecord::into_serialized_torrent) - .map(Ok); - Ok(futures::stream::iter(torrents).boxed()) - } -} - -struct PgBitfield { - torrent_id: TorrentIdOrHash, - inmem: BF, - pool: Pool, -} - -impl BitV for PgBitfield { - fn as_slice(&self) -> &bitvec::prelude::BitSlice { - self.inmem.as_bitslice() - } - - fn as_slice_mut(&mut self) -> &mut bitvec::prelude::BitSlice { - self.inmem.as_mut_bitslice() - } - - fn into_dyn(self) -> Box { - Box::new(self) - } - - fn as_bytes(&self) -> &[u8] { - self.inmem.as_raw_slice() - } - - fn flush(&mut self, _flush_async: bool) -> anyhow::Result<()> { - // TODO: make flush async, and don't spawn this, to avoid allocations and capture the result. - spawn( - debug_span!("pg_update_bitfield", id=?self.torrent_id), - "pg_update_bitfield", - { - let hb = self.as_bytes().to_owned(); - let pool = self.pool.clone(); - let torrent_id = self.torrent_id; - - macro_rules! exec { - ($q:expr, $bf:expr, $id:expr) => { - sqlx::query($q) - .bind($bf) - .bind($id) - .execute(&pool) - .await - .context($q) - .context("error executing query") - }; - } - - async move { - match torrent_id { - TorrentIdOrHash::Id(id) => { - let id: i32 = id.try_into()?; - exec!( - "UPDATE torrents SET have_bitfield = $1 WHERE id = $2", - &hb, - id - )?; - } - TorrentIdOrHash::Hash(h) => { - exec!( - "UPDATE torrents SET have_bitfield = $1 WHERE info_hash = $2", - &hb, - &h.0[..] - )?; - } - }; - Ok::<_, anyhow::Error>(()) - } - }, - ); - Ok(()) - } -} - -#[async_trait::async_trait] -impl BitVFactory for PostgresSessionStorage { - async fn load(&self, id: TorrentIdOrHash) -> anyhow::Result>> { - #[derive(sqlx::FromRow)] - struct HaveBitfield { - have_bitfield: Option>, - } - - macro_rules! exec { - ($q:expr, $v:expr) => { - sqlx::query_as($q) - .bind($v) - .fetch_one(&self.pool) - .await - .context($q) - .context("error executing query")? - }; - } - - let hb: HaveBitfield = match id { - TorrentIdOrHash::Id(id) => { - let id: i32 = id.try_into()?; - exec!("SELECT have_bitfield FROM torrents WHERE id = $1", id) - } - TorrentIdOrHash::Hash(h) => { - exec!( - "SELECT have_bitfield FROM torrents WHERE info_hash = $1", - &h.0[..] - ) - } - }; - - let hb = hb.have_bitfield; - Ok(hb.map(|b| { - PgBitfield { - torrent_id: id, - inmem: BF::from_boxed_slice(b.into_boxed_slice()), - pool: self.pool.clone(), - } - .into_dyn() - })) - } - - async fn store_initial_check( - &self, - id: TorrentIdOrHash, - b: BF, - ) -> anyhow::Result> { - let mut bf = PgBitfield { - torrent_id: id, - inmem: b, - pool: self.pool.clone(), - }; - bf.flush(false)?; - Ok(bf.into_dyn()) - } - - async fn clear(&self, id: TorrentIdOrHash) -> anyhow::Result<()> { - macro_rules! exec { - ($q:expr, $v:expr) => { - sqlx::query($q) - .bind($v) - .execute(&self.pool) - .await - .context($q) - .context("error executing query")? - }; - } - - match id { - TorrentIdOrHash::Id(id) => { - let id: i32 = id.try_into()?; - exec!("UPDATE torrents SET have_bitfield = NULL WHERE id = $1", id); - } - TorrentIdOrHash::Hash(h) => { - exec!( - "UPDATE torrents SET have_bitfield = NULL WHERE info_hash = $1", - &h.0[..] - ); - } - } - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_stats/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_stats/mod.rs deleted file mode 100644 index 45c5fde2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_stats/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::{ - sync::{Arc, atomic::Ordering}, - time::{Duration, Instant}, -}; - -use anyhow::Context; -use librtbit_core::speed_estimator::SpeedEstimator; -use snapshot::SessionStatsSnapshot; -use tracing::debug_span; - -use crate::{Session, torrent_state::peers::stats::AggregatePeerStatsAtomic}; - -pub mod snapshot; - -gen_stats!(SessionCountersAtomic SessionCountersSnapshot, [ - fetched_bytes u64, - uploaded_bytes u64, - blocked_incoming u64, - blocked_outgoing u64 -], []); - -pub struct SessionStats { - pub counters: SessionCountersAtomic, - pub peers: Arc, - pub down_speed_estimator: SpeedEstimator, - pub up_speed_estimator: SpeedEstimator, - pub startup_time: Instant, -} - -impl SessionStats { - pub fn new() -> Self { - SessionStats { - counters: Default::default(), - peers: Default::default(), - down_speed_estimator: SpeedEstimator::new(5), - up_speed_estimator: SpeedEstimator::new(5), - startup_time: Instant::now(), - } - } -} - -impl Default for SessionStats { - fn default() -> Self { - Self::new() - } -} - -impl Session { - pub(crate) fn start_speed_estimator_updater(self: &Arc) { - self.spawn( - debug_span!(parent: self.rs(), "speed_estimator"), - "speed_estimator", - { - let s = Arc::downgrade(self); - - async move { - let mut i = tokio::time::interval(Duration::from_secs(1)); - loop { - i.tick().await; - let s = s.upgrade().context("session is dead")?; - let now = Instant::now(); - let fetched = s.stats.counters.fetched_bytes.load(Ordering::Relaxed); - let uploaded = s.stats.counters.uploaded_bytes.load(Ordering::Relaxed); - s.stats - .down_speed_estimator - .add_snapshot(fetched, None, now); - s.stats.up_speed_estimator.add_snapshot(uploaded, None, now); - } - } - }, - ) - } - - pub fn stats_snapshot(&self) -> SessionStatsSnapshot { - SessionStatsSnapshot::from((&*self.stats, self.connector.stats().snapshot())) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_stats/snapshot.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_stats/snapshot.rs deleted file mode 100644 index b1aa33e0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/session_stats/snapshot.rs +++ /dev/null @@ -1,100 +0,0 @@ -use serde::Serialize; - -use crate::{ - session_stats::SessionCountersSnapshot, - stream_connect::ConnectStatsSnapshot, - torrent_state::{peers::stats::AggregatePeerStats, stats::Speed}, -}; - -use super::SessionStats; - -#[derive(Debug, Serialize)] -pub struct SessionStatsSnapshot { - pub counters: SessionCountersSnapshot, - pub download_speed: Speed, - pub upload_speed: Speed, - pub peers: AggregatePeerStats, - pub uptime_seconds: u64, - pub connections: ConnectStatsSnapshot, -} - -impl From<(&SessionStats, ConnectStatsSnapshot)> for SessionStatsSnapshot { - fn from((s, c): (&SessionStats, ConnectStatsSnapshot)) -> Self { - Self { - download_speed: s.down_speed_estimator.mbps().into(), - upload_speed: s.up_speed_estimator.mbps().into(), - counters: s.counters.snapshot(), - peers: s.peers.snapshot(), - uptime_seconds: s.startup_time.elapsed().as_secs(), - connections: c, - } - } -} - -impl SessionStatsSnapshot { - pub fn as_prometheus(&self, mut out: &mut String) { - use core::fmt::Write; - - out.push('\n'); - - macro_rules! m { - ($type:ident, $name:ident, $value:expr) => {{ - writeln!( - &mut out, - concat!("# TYPE ", stringify!($name), " ", stringify!($type)) - ) - .unwrap(); - writeln!(&mut out, concat!(stringify!($name), " {}"), $value).unwrap(); - }}; - } - - m!(counter, rtbit_fetched_bytes, self.counters.fetched_bytes); - m!(counter, rtbit_uploaded_bytes, self.counters.uploaded_bytes); - m!( - counter, - rtbit_blocked_incoming, - self.counters.blocked_incoming - ); - m!( - counter, - rtbit_blocked_outgoing, - self.counters.blocked_outgoing - ); - m!( - gauge, - rtbit_download_speed_bytes, - self.download_speed.as_bytes() - ); - m!( - gauge, - rtbit_upload_speed_bytes, - self.upload_speed.as_bytes() - ); - m!(gauge, rtbit_uptime_seconds, self.uptime_seconds); - m!(gauge, rtbit_peers_connecting, self.peers.connecting); - writeln!(&mut out, "# TYPE rtbit_peers_live gauge").unwrap(); - writeln!( - &mut out, - "rtbit_peers_live{{kind=\"tcp\"}} {}", - self.peers.live_tcp - ) - .unwrap(); - writeln!( - &mut out, - "rtbit_peers_live{{kind=\"utp\"}} {}", - self.peers.live_utp - ) - .unwrap(); - writeln!( - &mut out, - "rtbit_peers_live{{kind=\"socks\"}} {}", - self.peers.live_socks - ) - .unwrap(); - m!(gauge, rtbit_peers_dead, self.peers.dead); - m!(gauge, rtbit_peers_not_needed, self.peers.not_needed); - m!(gauge, rtbit_peers_queued, self.peers.queued); - m!(gauge, rtbit_peers_seen, self.peers.seen); - m!(gauge, rtbit_peers_steals, self.peers.steals); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/spawn_utils.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/spawn_utils.rs deleted file mode 100644 index 01dd8609..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/spawn_utils.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::sync::Arc; -use tokio::sync::Semaphore; - -/// A tool to limit the number of blocking threads used concurrently to prevent -/// runtime starvation. -/// -/// Also shortcuts for single-threaded tokio runtime to simply call the function, unlike -/// "tokio::task::block_in_place" which would panic. -#[derive(Clone, Debug)] -pub struct BlockingSpawner { - allow_block_in_place: bool, - concurrent_block_in_place_semaphore: Arc, -} - -impl BlockingSpawner { - pub fn new(max_blocking_threads: usize) -> Self { - let handle = tokio::runtime::Handle::current(); - let allow_block_in_place = match handle.runtime_flavor() { - tokio::runtime::RuntimeFlavor::CurrentThread => false, - tokio::runtime::RuntimeFlavor::MultiThread => true, - _ => true, - }; - Self { - allow_block_in_place, - concurrent_block_in_place_semaphore: Arc::new(Semaphore::new( - max_blocking_threads.max(1), - )), - } - } - - /// Only call this if you can't call the async function block_in_place_with_semaphore - /// E.g. if you you have non-send objects on the stack. - pub fn block_in_place R, R>(&self, f: F) -> R { - if self.allow_block_in_place { - return tokio::task::block_in_place(f); - } - - f() - } - - /// like "block_in_place" but limit concurrency. - pub async fn block_in_place_with_semaphore R, R>(&self, f: F) -> R { - if self.allow_block_in_place { - let _permit = self - .concurrent_block_in_place_semaphore - .acquire() - .await - .unwrap(); - return tokio::task::block_in_place(f); - } - - f() - } - - pub fn semaphore(&self) -> Arc { - self.concurrent_block_in_place_semaphore.clone() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/stat_gen.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/stat_gen.rs deleted file mode 100644 index 8784f694..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/stat_gen.rs +++ /dev/null @@ -1,61 +0,0 @@ -macro_rules! stype { - (atomic u32) => { - std::sync::atomic::AtomicU32 - }; - (atomic u64) => { - std::sync::atomic::AtomicU64 - }; - (u32) => { - u32 - }; - (u64) => { - u64 - }; -} - -macro_rules! gen_stats { - ($atomic_name:ident $snapshot_name:ident, [$($stat_name:ident $stat_ty:tt),*], [$($nested_field_name:ident $nested_atomic_name:ident $nested_snapshot_name:ident),*]) => { - #[derive(Debug, Default)] - pub struct $atomic_name { - $( - pub $stat_name: stype!(atomic $stat_ty), - )* - - $( - pub $nested_field_name: $nested_atomic_name, - )* - } - - impl $atomic_name { - pub fn snapshot(&self) -> $snapshot_name { - $snapshot_name { - $( - $stat_name: self.$stat_name.load(std::sync::atomic::Ordering::Relaxed), - )* - - $( - $nested_field_name: self.$nested_field_name.snapshot(), - )* - } - } - - $( - #[allow(unused)] - pub fn $stat_name(&self, value: $stat_ty) { - self.$stat_name.fetch_add(value, std::sync::atomic::Ordering::Relaxed); - } - )* - } - - #[derive(Debug, Default, serde::Serialize)] - pub struct $snapshot_name { - $( - pub $stat_name: stype!($stat_ty), - )* - - $( - pub $nested_field_name: $nested_snapshot_name, - )* - } - }; -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/inmemory.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/inmemory.rs deleted file mode 100644 index 6ccde899..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/inmemory.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::{collections::HashMap, path::Path}; - -use anyhow::Context; -use librtbit_core::lengths::{Lengths, ValidPieceIndex}; -use parking_lot::RwLock; - -use crate::type_aliases::FileInfos; - -use crate::storage::{StorageFactory, StorageFactoryExt, TorrentStorage}; - -struct InMemoryPiece { - bytes: Box<[u8]>, -} - -impl InMemoryPiece { - fn new(l: &Lengths) -> Self { - let v = vec![0; l.default_piece_length() as usize].into_boxed_slice(); - Self { bytes: v } - } -} - -#[derive(Default, Clone)] -pub struct InMemoryExampleStorageFactory {} - -impl StorageFactory for InMemoryExampleStorageFactory { - type Storage = InMemoryExampleStorage; - - fn create( - &self, - info: &crate::torrent_state::ManagedTorrentShared, - ) -> anyhow::Result { - InMemoryExampleStorage::new(info.lengths, info.file_infos.clone()) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.clone().boxed() - } -} - -pub struct InMemoryExampleStorage { - lengths: Lengths, - file_infos: FileInfos, - map: RwLock>, -} - -impl InMemoryExampleStorage { - fn new(lengths: Lengths, file_infos: FileInfos) -> anyhow::Result { - // Max memory 128MiB. Make it tunable - let max_pieces = 128 * 1024 * 1024 / lengths.default_piece_length(); - if max_pieces == 0 { - anyhow::bail!("pieces too large"); - } - - Ok(Self { - lengths, - file_infos, - map: RwLock::new(HashMap::new()), - }) - } -} - -impl TorrentStorage for InMemoryExampleStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - let fi = &self.file_infos[file_id]; - let abs_offset = fi.offset_in_torrent + offset; - let piece_id: u32 = (abs_offset / self.lengths.default_piece_length() as u64).try_into()?; - let piece_offset: usize = - (abs_offset % self.lengths.default_piece_length() as u64).try_into()?; - let piece_id = self.lengths.validate_piece_index(piece_id).context("bug")?; - - let g = self.map.read(); - let inmp = g.get(&piece_id).context("piece expired")?; - buf.copy_from_slice(&inmp.bytes[piece_offset..(piece_offset + buf.len())]); - Ok(()) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - let fi = &self.file_infos[file_id]; - let abs_offset = fi.offset_in_torrent + offset; - let piece_id: u32 = (abs_offset / self.lengths.default_piece_length() as u64).try_into()?; - let piece_offset: usize = - (abs_offset % self.lengths.default_piece_length() as u64).try_into()?; - let piece_id = self.lengths.validate_piece_index(piece_id).context("bug")?; - let mut g = self.map.write(); - let inmp = g - .entry(piece_id) - .or_insert_with(|| InMemoryPiece::new(&self.lengths)); - inmp.bytes[piece_offset..(piece_offset + buf.len())].copy_from_slice(buf); - Ok(()) - } - - fn remove_file(&self, _file_id: usize, _filename: &Path) -> anyhow::Result<()> { - Ok(()) - } - - fn ensure_file_length(&self, _file_id: usize, _length: u64) -> anyhow::Result<()> { - Ok(()) - } - - fn take(&self) -> anyhow::Result> { - let map = { - let mut g = self.map.write(); - let mut repl = HashMap::new(); - std::mem::swap(&mut *g, &mut repl); - repl - }; - Ok(Box::new(Self { - lengths: self.lengths, - map: RwLock::new(map), - file_infos: self.file_infos.clone(), - })) - } - - fn init(&mut self, _meta: &crate::ManagedTorrentShared) -> anyhow::Result<()> { - Ok(()) - } - - fn remove_directory_if_empty(&self, _path: &Path) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/mmap.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/mmap.rs deleted file mode 100644 index 6b58ebc9..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/mmap.rs +++ /dev/null @@ -1,77 +0,0 @@ -use anyhow::Context; -use memmap2::{MmapMut, MmapOptions}; -use parking_lot::RwLock; - -use crate::{ - FileInfos, ManagedTorrentShared, - storage::{StorageFactory, StorageFactoryExt, TorrentStorage}, -}; - -#[derive(Default, Clone)] -pub struct MmapStorageFactory {} - -pub struct MmapStorage { - mmap: RwLock, - file_infos: FileInfos, -} - -impl StorageFactory for MmapStorageFactory { - type Storage = MmapStorage; - - fn create(&self, info: &ManagedTorrentShared) -> anyhow::Result { - Ok(MmapStorage { - mmap: RwLock::new( - MmapOptions::new() - .len(info.lengths.total_length().try_into()?) - .map_anon()?, - ), - file_infos: info.file_infos.clone(), - }) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.clone().boxed() - } -} - -impl TorrentStorage for MmapStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - let start: usize = (self.file_infos[file_id].offset_in_torrent + offset).try_into()?; - let end = start + buf.len(); - buf.copy_from_slice(self.mmap.read().get(start..end).context("bad range")?); - Ok(()) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - let start: usize = (self.file_infos[file_id].offset_in_torrent + offset).try_into()?; - let end = start + buf.len(); - let mut g = self.mmap.write(); - let target = g.get_mut(start..end).context("bad range")?; - target.copy_from_slice(buf); - Ok(()) - } - - fn remove_file(&self, _file_id: usize, _filename: &std::path::Path) -> anyhow::Result<()> { - Ok(()) - } - - fn ensure_file_length(&self, _file_id: usize, _length: u64) -> anyhow::Result<()> { - Ok(()) - } - - fn take(&self) -> anyhow::Result> { - anyhow::bail!("not implemented") - } - - fn init(&mut self, _meta: &ManagedTorrentShared) -> anyhow::Result<()> { - Ok(()) - } - - fn remove_directory_if_empty(&self, _path: &std::path::Path) -> anyhow::Result<()> { - Ok(()) - } - - fn on_piece_completed(&self, _file_id: usize, _offset: u64) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/mod.rs deleted file mode 100644 index 0ac3ad05..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/examples/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod inmemory; -pub mod mmap; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/fs.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/fs.rs deleted file mode 100644 index a4f9e0d4..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/fs.rs +++ /dev/null @@ -1,398 +0,0 @@ -use std::{ - fs::OpenOptions, - io::IoSlice, - path::{Path, PathBuf}, -}; - -use anyhow::Context; -use tracing::warn; - -use crate::{ - storage::{StorageFactoryExt, filesystem::opened_file::OurFileExt}, - torrent_state::{ManagedTorrentShared, TorrentMetadata}, -}; - -use crate::storage::{StorageFactory, TorrentStorage}; - -use super::opened_file::OpenedFile; - -#[derive(Default, Clone, Copy)] -pub struct FilesystemStorageFactory {} - -impl StorageFactory for FilesystemStorageFactory { - type Storage = FilesystemStorage; - - fn create( - &self, - shared: &ManagedTorrentShared, - _metadata: &TorrentMetadata, - ) -> anyhow::Result { - Ok(FilesystemStorage { - output_folder: shared.options.output_folder.clone(), - opened_files: Default::default(), - }) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.boxed() - } -} - -pub struct FilesystemStorage { - pub(super) output_folder: PathBuf, - pub(super) opened_files: Vec, -} - -impl FilesystemStorage { - pub(super) fn take_fs(&self) -> anyhow::Result { - Ok(Self { - opened_files: self - .opened_files - .iter() - .map(|f| f.take_clone()) - .collect::>>()?, - output_folder: self.output_folder.clone(), - }) - } -} - -impl TorrentStorage for FilesystemStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - self.opened_files - .get(file_id) - .context("no such file")? - .lock_read()? - .pread_exact(offset, buf) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - let of = self.opened_files.get(file_id).context("no such file")?; - #[cfg(windows)] - return of.try_mark_sparse()?.pwrite_all(offset, buf); - #[cfg(not(windows))] - return of.lock_read()?.pwrite_all(offset, buf); - } - - fn pwrite_all_vectored( - &self, - file_id: usize, - offset: u64, - bufs: [IoSlice<'_>; 2], - ) -> anyhow::Result { - let of = self.opened_files.get(file_id).context("no such file")?; - #[cfg(windows)] - return of.try_mark_sparse()?.pwrite_all_vectored(offset, bufs); - #[cfg(not(windows))] - return of.lock_read()?.pwrite_all_vectored(offset, bufs); - } - - fn remove_file(&self, _file_id: usize, filename: &Path) -> anyhow::Result<()> { - Ok(std::fs::remove_file(self.output_folder.join(filename))?) - } - - fn ensure_file_length(&self, file_id: usize, len: u64) -> anyhow::Result<()> { - let f = &self.opened_files.get(file_id).context("no such file")?; - #[cfg(windows)] - f.try_mark_sparse()?; - Ok(f.lock_read()?.set_len(len)?) - } - - fn take(&self) -> anyhow::Result> { - Ok(Box::new(Self { - opened_files: self - .opened_files - .iter() - .map(|f| f.take_clone()) - .collect::>>()?, - output_folder: self.output_folder.clone(), - })) - } - - fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { - let path = self.output_folder.join(path); - if !path.is_dir() { - anyhow::bail!("cannot remove dir: {path:?} is not a directory") - } - // Attempt removal directly to avoid TOCTOU race between empty check and remove. - // std::fs::remove_dir only removes empty directories; if non-empty it errors. - match std::fs::remove_dir(&path) { - Ok(()) => Ok(()), - Err(e) if matches!(e.kind(), std::io::ErrorKind::DirectoryNotEmpty) => { - warn!("did not remove {path:?} as it was not empty"); - Ok(()) - } - Err(e) => Err(e).with_context(|| format!("error removing {path:?}")), - } - } - - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()> { - let mut files = Vec::::new(); - for file_details in metadata.file_infos.iter() { - let mut full_path = self.output_folder.clone(); - let relative_path = &file_details.relative_filename; - full_path.push(relative_path); - - if file_details.attrs.padding { - files.push(OpenedFile::new_dummy()); - continue; - }; - std::fs::create_dir_all(full_path.parent().context("bug: no parent")?)?; - let f = if shared.options.allow_overwrite { - OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&full_path) - .with_context(|| format!("error opening {full_path:?} in read/write mode"))? - } else { - // create_new does not seem to work with read(true), so calling this twice. - OpenOptions::new() - .create_new(true) - .write(true) - .open(&full_path) - .with_context(|| { - format!( - "error creating a new file (because allow_overwrite = false) {:?}", - &full_path - ) - })?; - OpenOptions::new().read(true).write(true).open(&full_path)? - }; - files.push(OpenedFile::new(full_path.clone(), f)); - } - - self.opened_files = files; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::storage::TorrentStorage; - - /// Helper to create a FilesystemStorage with `n` real files in a temp directory. - /// Returns (storage, tempdir) -- tempdir must be kept alive for the duration of the test. - fn make_test_storage(n: usize) -> (FilesystemStorage, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let mut opened_files = Vec::new(); - for i in 0..n { - let path = dir.path().join(format!("file_{i}.dat")); - let f = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&path) - .unwrap(); - opened_files.push(OpenedFile::new(path, f)); - } - let storage = FilesystemStorage { - output_folder: dir.path().to_owned(), - opened_files, - }; - (storage, dir) - } - - #[test] - fn test_filesystem_storage_write_and_read() { - let (storage, _dir) = make_test_storage(1); - - // Ensure the file is long enough. - storage.ensure_file_length(0, 1024).unwrap(); - - let write_data = b"hello, world! this is test data."; - storage.pwrite_all(0, 0, write_data).unwrap(); - - let mut read_buf = vec![0u8; write_data.len()]; - storage.pread_exact(0, 0, &mut read_buf).unwrap(); - - assert_eq!(&read_buf, write_data); - } - - #[test] - fn test_filesystem_storage_write_at_offset() { - let (storage, _dir) = make_test_storage(1); - - storage.ensure_file_length(0, 4096).unwrap(); - - let write_data = b"offset_data"; - let offset = 2048; - storage.pwrite_all(0, offset, write_data).unwrap(); - - let mut read_buf = vec![0u8; write_data.len()]; - storage.pread_exact(0, offset, &mut read_buf).unwrap(); - - assert_eq!(&read_buf, write_data); - } - - #[test] - fn test_filesystem_storage_piece_hash_verification() { - use sha1w::ISha1; - - let (storage, _dir) = make_test_storage(1); - - storage.ensure_file_length(0, 16384).unwrap(); - - // Write a "piece" of known data. - #[allow(clippy::cast_possible_truncation)] - let piece_data: Vec = (0..16384u32).map(|i| (i % 256) as u8).collect(); - storage.pwrite_all(0, 0, &piece_data).unwrap(); - - // Compute expected hash. - let mut hasher = sha1w::Sha1::new(); - hasher.update(&piece_data); - let expected_hash = hasher.finish(); - - // Read back and compute hash. - let mut read_buf = vec![0u8; 16384]; - storage.pread_exact(0, 0, &mut read_buf).unwrap(); - - let mut hasher2 = sha1w::Sha1::new(); - hasher2.update(&read_buf); - let actual_hash = hasher2.finish(); - - assert_eq!(expected_hash, actual_hash); - } - - #[test] - fn test_storage_handles_sparse_writes() { - let (storage, _dir) = make_test_storage(1); - - storage.ensure_file_length(0, 8192).unwrap(); - - // Write at non-sequential offsets (sparse writes). - let data_a = b"chunk_a"; - let data_b = b"chunk_b"; - - // Write chunk B first (at offset 4096), then chunk A (at offset 0). - storage.pwrite_all(0, 4096, data_b).unwrap(); - storage.pwrite_all(0, 0, data_a).unwrap(); - - // Read both back. - let mut buf_a = vec![0u8; data_a.len()]; - let mut buf_b = vec![0u8; data_b.len()]; - storage.pread_exact(0, 0, &mut buf_a).unwrap(); - storage.pread_exact(0, 4096, &mut buf_b).unwrap(); - - assert_eq!(&buf_a, data_a); - assert_eq!(&buf_b, data_b); - } - - #[test] - fn test_storage_file_boundaries() { - // Test writing to multiple files (pieces spanning file boundaries). - let (storage, _dir) = make_test_storage(3); - - for i in 0..3 { - storage.ensure_file_length(i, 1024).unwrap(); - } - - // Write different data to each file. - let data = [b"file0_data", b"file1_data", b"file2_data"]; - for (i, d) in data.iter().enumerate() { - storage.pwrite_all(i, 0, *d).unwrap(); - } - - // Read back from each file and verify. - for (i, d) in data.iter().enumerate() { - let mut buf = vec![0u8; d.len()]; - storage.pread_exact(i, 0, &mut buf).unwrap(); - assert_eq!(&buf, *d, "file {i} data mismatch"); - } - } - - #[test] - fn test_storage_ensure_file_length() { - let (storage, dir) = make_test_storage(1); - - // Set to 4096 bytes. - storage.ensure_file_length(0, 4096).unwrap(); - let meta = std::fs::metadata(dir.path().join("file_0.dat")).unwrap(); - assert_eq!(meta.len(), 4096); - - // Grow to 8192 bytes. - storage.ensure_file_length(0, 8192).unwrap(); - let meta = std::fs::metadata(dir.path().join("file_0.dat")).unwrap(); - assert_eq!(meta.len(), 8192); - } - - #[test] - fn test_storage_remove_file() { - let (storage, dir) = make_test_storage(2); - - let file0_path = dir.path().join("file_0.dat"); - let file1_path = dir.path().join("file_1.dat"); - assert!(file0_path.exists()); - assert!(file1_path.exists()); - - // Remove file 0. - storage.remove_file(0, Path::new("file_0.dat")).unwrap(); - assert!(!file0_path.exists()); - // File 1 should still exist. - assert!(file1_path.exists()); - } - - #[test] - fn test_storage_take() { - let (storage, _dir) = make_test_storage(1); - - storage.ensure_file_length(0, 1024).unwrap(); - storage.pwrite_all(0, 0, b"before_take").unwrap(); - - let taken = storage.take().unwrap(); - - // The taken storage should be able to read the same data. - let mut buf = vec![0u8; 11]; - taken.pread_exact(0, 0, &mut buf).unwrap(); - assert_eq!(&buf, b"before_take"); - } - - #[test] - fn test_storage_invalid_file_id() { - let (storage, _dir) = make_test_storage(1); - - // File ID 5 does not exist. - let result = storage.pread_exact(5, 0, &mut [0u8; 10]); - assert!(result.is_err()); - - let result = storage.pwrite_all(5, 0, &[0u8; 10]); - assert!(result.is_err()); - } - - #[test] - fn test_storage_remove_directory_if_empty() { - let (storage, dir) = make_test_storage(0); - - // Create a subdirectory. - let sub = dir.path().join("subdir"); - std::fs::create_dir(&sub).unwrap(); - - // Remove empty directory should succeed. - storage - .remove_directory_if_empty(Path::new("subdir")) - .unwrap(); - assert!(!sub.exists()); - } - - #[test] - fn test_storage_remove_directory_not_empty() { - let (storage, dir) = make_test_storage(0); - - // Create a subdirectory with a file inside. - let sub = dir.path().join("subdir"); - std::fs::create_dir(&sub).unwrap(); - std::fs::write(sub.join("file.txt"), b"content").unwrap(); - - // Removing a non-empty directory should not delete it (just warn). - storage - .remove_directory_if_empty(Path::new("subdir")) - .unwrap(); - assert!(sub.exists()); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/mmap.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/mmap.rs deleted file mode 100644 index 4a77151f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/mmap.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::path::Path; - -use anyhow::Context; -use memmap2::{MmapMut, MmapOptions}; -use parking_lot::RwLock; - -use crate::torrent_state::{ManagedTorrentShared, TorrentMetadata}; - -use crate::storage::{StorageFactory, StorageFactoryExt, TorrentStorage}; - -use super::{FilesystemStorage, FilesystemStorageFactory}; - -#[derive(Default, Clone, Copy)] -pub struct MmapFilesystemStorageFactory {} - -type OpenedMmap = RwLock; - -fn dummy_mmap() -> anyhow::Result { - Ok(memmap2::MmapOptions::new().len(1).map_anon()?) -} - -impl StorageFactory for MmapFilesystemStorageFactory { - type Storage = MmapFilesystemStorage; - - fn create( - &self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - let fs_storage = FilesystemStorageFactory::default().create(shared, metadata)?; - - Ok(MmapFilesystemStorage { - opened_mmaps: Vec::new(), - fs: fs_storage, - }) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.boxed() - } -} - -pub struct MmapFilesystemStorage { - opened_mmaps: Vec, - fs: FilesystemStorage, -} - -impl TorrentStorage for MmapFilesystemStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - let g = self - .opened_mmaps - .get(file_id) - .context("no such file")? - .read(); - let start = offset; - let end = offset + buf.len() as u64; - let start = start.try_into()?; - let end = end.try_into()?; - buf.copy_from_slice(g.get(start..end).context("bug")?); - Ok(()) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - let mut g = self - .opened_mmaps - .get(file_id) - .context("no such file")? - .write(); - let start = offset; - let end = offset + buf.len() as u64; - let start = start.try_into()?; - let end = end.try_into()?; - g.get_mut(start..end).context("bug")?.copy_from_slice(buf); - Ok(()) - } - - fn remove_file(&self, file_id: usize, filename: &Path) -> anyhow::Result<()> { - self.fs.remove_file(file_id, filename) - } - - fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { - self.fs.remove_directory_if_empty(path) - } - - fn ensure_file_length(&self, file_id: usize, len: u64) -> anyhow::Result<()> { - self.fs.ensure_file_length(file_id, len) - } - - fn take(&self) -> anyhow::Result> { - Ok(Box::new(Self { - opened_mmaps: self - .opened_mmaps - .iter() - .map(|m| { - let d = dummy_mmap()?; - let mut g = m.write(); - Ok::<_, anyhow::Error>(RwLock::new(std::mem::replace(&mut *g, d))) - }) - .collect::>()?, - fs: self.fs.take_fs()?, - })) - } - - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()> { - self.fs.init(shared, metadata)?; - let mut mmaps = Vec::new(); - for (idx, file) in self.fs.opened_files.iter().enumerate() { - let fg = file.lock_write()?; - fg.set_len(metadata.file_infos[idx].len) - .context("mmap storage: error setting length")?; - let mmap = unsafe { MmapOptions::new().map_mut(&*fg) }.context("error mapping file")?; - mmaps.push(RwLock::new(mmap)); - } - - self.opened_mmaps = mmaps; - Ok(()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/mod.rs deleted file mode 100644 index 9e80d94e..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod fs; -mod mmap; -mod opened_file; -mod sparse; - -pub use fs::{FilesystemStorage, FilesystemStorageFactory}; -pub use mmap::{MmapFilesystemStorage, MmapFilesystemStorageFactory}; -pub use opened_file::OurFileExt; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/opened_file.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/opened_file.rs deleted file mode 100644 index 3bd49e3c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/opened_file.rs +++ /dev/null @@ -1,265 +0,0 @@ -use std::{ - fs::File, - io::IoSlice, - ops::{Deref, DerefMut}, - path::PathBuf, -}; - -use anyhow::Context; -use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; - -use crate::Error; - -pub trait OurFileExt { - fn pwrite_all_vectored(&self, offset: u64, bufs: [IoSlice<'_>; 2]) -> anyhow::Result; - fn pread_exact(&self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()>; - fn pwrite_all(&self, offset: u64, buf: &[u8]) -> anyhow::Result<()>; -} - -impl OurFileExt for File { - #[cfg(unix)] - fn pwrite_all_vectored(&self, offset: u64, bufs: [IoSlice<'_>; 2]) -> anyhow::Result { - let offset_i64: i64 = offset.try_into().with_context(|| { - format!("file write offset {offset} exceeds i64::MAX, cannot pass to pwritev") - })?; - nix::sys::uio::pwritev(self, &bufs, offset_i64).context("error calling pwritev") - } - - #[cfg(not(unix))] - fn pwrite_all_vectored(&self, offset: u64, bufs: [IoSlice<'_>; 2]) -> anyhow::Result { - match (bufs[0].len(), bufs[1].len()) { - (len, 0) if len > 0 => { - self.pwrite_all(offset, &bufs[0])?; - Ok(len) - } - (0, len) if len > 0 => { - self.pwrite_all(offset, &bufs[1])?; - Ok(len) - } - (0, 0) => Ok(0), - (l0, l1) => { - // concatenate the buffers in memory so that we issue one write call instead of 2 - // assumes the message is <= CHUNK_SIZE - use librtbit_core::constants::CHUNK_SIZE; - let mut buf = [0u8; CHUNK_SIZE as usize]; - - buf.get_mut(..l0) - .context("buf too small")? - .copy_from_slice(&bufs[0]); - buf.get_mut(l0..l0 + l1) - .context("buf too small")? - .copy_from_slice(&bufs[1]); - self.pwrite_all(offset, &buf[..l0 + l1])?; - Ok(l0 + l1) - } - } - } - - #[cfg(unix)] - fn pread_exact(&self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - use std::os::unix::fs::FileExt; - - Ok(self.read_exact_at(buf, offset)?) - } - - #[cfg(windows)] - fn pread_exact(&self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - use std::os::windows::fs::FileExt; - self.seek_read(buf, offset)?; - Ok(()) - } - - #[cfg(not(any(windows, unix)))] - fn pread_exact(&self, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - anyhow::bail!("pread_exact not implemented for your platform") - } - - #[cfg(unix)] - fn pwrite_all(&self, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - use std::os::unix::fs::FileExt; - Ok(self.write_all_at(buf, offset)?) - } - - #[cfg(windows)] - fn pwrite_all(&self, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - use std::os::windows::fs::FileExt; - - let mut remaining = buf.len(); - let mut buf = buf; - let mut offset = offset; - while remaining > 0 { - let written = self.seek_write(&buf[..remaining], offset)?; - remaining -= written; - offset += written as u64; - buf = &buf[written..]; - } - Ok(()) - } - - #[cfg(not(any(windows, unix)))] - fn pwrite_all(&self, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - anyhow::bail!("pwrite_all not implemented for your platform") - } -} - -#[derive(Default, Debug)] -struct OpenedFileLocked { - #[allow(unused)] - path: PathBuf, - fd: Option, - #[cfg(windows)] - tried_marking_sparse: bool, -} - -impl Deref for OpenedFileLocked { - type Target = Option; - - fn deref(&self) -> &Self::Target { - &self.fd - } -} - -impl DerefMut for OpenedFileLocked { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.fd - } -} - -#[derive(Debug)] -pub(crate) struct OpenedFile { - file: RwLock, -} - -impl OpenedFile { - pub fn new(path: PathBuf, f: File) -> Self { - Self { - file: RwLock::new(OpenedFileLocked { - path, - fd: Some(f), - #[cfg(windows)] - tried_marking_sparse: false, - }), - } - } - - pub fn new_dummy() -> Self { - Self { - file: RwLock::new(Default::default()), - } - } - - pub fn take_clone(&self) -> anyhow::Result { - let f = std::mem::take(&mut *self.file.write()); - Ok(Self { - file: RwLock::new(f), - }) - } - - pub fn lock_read(&self) -> crate::Result> { - RwLockReadGuard::try_map(self.file.read(), |f| f.as_ref()) - .ok() - .ok_or(Error::FsFileIsNone) - } - - pub fn lock_write(&self) -> crate::Result> { - RwLockWriteGuard::try_map(self.file.write(), |f| f.as_mut()) - .ok() - .ok_or(Error::FsFileIsNone) - } - - #[cfg(windows)] - pub fn try_mark_sparse(&self) -> crate::Result> { - { - let g = self.file.read(); - if g.tried_marking_sparse { - return RwLockReadGuard::try_map(g, |f| f.fd.as_ref()) - .ok() - .ok_or(Error::FsFileIsNone); - } - } - let mut g = self.file.write(); - if !g.tried_marking_sparse { - g.tried_marking_sparse = true; - let f = g.fd.as_ref().ok_or(Error::FsFileIsNone)?; - tracing::debug!(path=?g.path, marked=super::sparse::mark_file_sparse(&f), "marking sparse"); - } - let g = parking_lot::RwLockWriteGuard::downgrade(g); - Ok(RwLockReadGuard::try_map(g, |f| f.fd.as_ref()).ok().unwrap()) - } -} - -#[cfg(test)] -mod tests { - use std::io::Read; - - use librtbit_core::constants::CHUNK_SIZE; - use peer_binary_protocol::DoubleBufHelper; - use tempfile::TempDir; - - use crate::storage::filesystem::opened_file::OurFileExt; - - #[test] - fn test_write_path_large_file_offsets() { - // Test that offsets exceeding i64::MAX produce a descriptive error - // instead of panicking with "out of range integral type conversion attempted". - // This is the root cause of issue #477. - let td = TempDir::with_prefix("test_write_large_offset").unwrap(); - let path = td.path().join("test_file"); - let file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - .unwrap(); - - let buf = [42u8; 16]; - let bufs = [std::io::IoSlice::new(&buf), std::io::IoSlice::new(&[])]; - - // An offset that exceeds i64::MAX should return an error, not panic - let huge_offset: u64 = i64::MAX as u64 + 1; - let result = file.pwrite_all_vectored(huge_offset, bufs); - assert!(result.is_err(), "offset > i64::MAX should return error"); - let err_msg = format!("{:#}", result.unwrap_err()); - assert!( - err_msg.contains("exceeds i64::MAX"), - "error should contain descriptive message, got: {err_msg}" - ); - - // u64::MAX should also error - let result = file.pwrite_all_vectored( - u64::MAX, - [std::io::IoSlice::new(&buf), std::io::IoSlice::new(&[])], - ); - assert!(result.is_err(), "u64::MAX offset should return error"); - - // A valid offset should succeed - let result = - file.pwrite_all_vectored(0, [std::io::IoSlice::new(&buf), std::io::IoSlice::new(&[])]); - assert!(result.is_ok(), "offset 0 should succeed"); - } - - #[test] - fn test_pwrite_all_vectored() { - let td = TempDir::with_prefix("test_pwrite_all_vectored").unwrap(); - let mut tmp_buf = [0u8; CHUNK_SIZE as usize]; - for bufsize in [10000usize, CHUNK_SIZE as usize] { - let mut buf = vec![0u8; bufsize]; - rand::fill(&mut buf[..]); - for split_point in [0, bufsize / 2, bufsize] { - let path = td.path().join(format!("file_{bufsize}_{split_point}")); - let file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - .unwrap(); - let (first, second) = buf.split_at(split_point); - let bufs = DoubleBufHelper::new(first, second).as_ioslices(bufsize); - file.pwrite_all_vectored(0, bufs).unwrap(); - - let mut file = std::fs::File::open(&path).unwrap(); - assert_eq!(file.metadata().unwrap().len(), bufsize as u64, "{path:?}"); - file.read_exact(&mut tmp_buf[..bufsize]).unwrap(); - assert_eq!(&tmp_buf[..bufsize], buf); - } - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/sparse.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/sparse.rs deleted file mode 100644 index 34313eaf..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/filesystem/sparse.rs +++ /dev/null @@ -1,12 +0,0 @@ -#[cfg(windows)] -pub fn mark_file_sparse(f: &std::fs::File) -> bool { - use std::os::windows::io::AsRawHandle; - use windows::{ - Win32::Foundation::HANDLE, Win32::System::IO::DeviceIoControl, - Win32::System::Ioctl::FSCTL_SET_SPARSE, - }; - - let handle = HANDLE(f.as_raw_handle()); - - unsafe { DeviceIoControl(handle, FSCTL_SET_SPARSE, None, 0, None, 0, None, None).is_ok() } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/mod.rs deleted file mode 100644 index fb4bb3e2..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod slow; -pub mod timing; -pub mod write_through_cache; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/slow.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/slow.rs deleted file mode 100644 index e8c8571b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/slow.rs +++ /dev/null @@ -1,131 +0,0 @@ -/* -A storage middleware that slows down the underlying storage. - -To be used for debugging. The input to it are 2 files with line-delimited numbers of microseconds -it takes to read and write respectively. - -*/ - -use std::{ - fs::File, - io::{BufRead, BufReader}, - time::Duration, -}; - -use parking_lot::Mutex; - -use crate::{ - ManagedTorrentShared, - storage::{StorageFactory, StorageFactoryExt, TorrentStorage}, - torrent_state::TorrentMetadata, -}; - -#[derive(Clone)] -pub struct SlowStorageFactory { - underlying_factory: U, -} - -impl SlowStorageFactory { - pub fn new(underlying: U) -> Self { - Self { - underlying_factory: underlying, - } - } -} - -impl StorageFactory for SlowStorageFactory { - type Storage = SlowStorage; - - fn create( - &self, - shared: &crate::ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - Ok(SlowStorage { - underlying: self.underlying_factory.create(shared, metadata)?, - pwrite_all_bufread: Mutex::new(Box::new( - BufReader::new( - File::open( - std::env::var("DBG_PWRITE_ALL_FILENAME") - .expect("DBG_PWRITE_ALL_FILENAME env var not there"), - ) - .unwrap(), - ) - .lines() - .map(|l| l.unwrap().parse().unwrap()) - .collect::>() - .into_iter(), - )), - pread_exact_bufread: Mutex::new(Box::new( - BufReader::new( - File::open( - std::env::var("DBG_PWRITE_ALL_FILENAME") - .expect("DBG_PWRITE_ALL_FILENAME env var not there"), - ) - .unwrap(), - ) - .lines() - .map(|l| l.unwrap().parse().unwrap()) - .collect::>() - .into_iter(), - )), - }) - } - - fn is_type_id(&self, type_id: std::any::TypeId) -> bool { - self.underlying_factory.is_type_id(type_id) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.clone().boxed() - } -} - -pub struct SlowStorage { - underlying: U, - pwrite_all_bufread: Mutex + Send + Sync>>, - pread_exact_bufread: Mutex + Send + Sync>>, -} - -fn sleep_from_reader(r: &Mutex + Send + Sync>>) { - let mut g = r.lock(); - let micros: u64 = g.next().unwrap(); - let sl = Duration::from_micros(micros); - std::thread::sleep(sl) -} - -impl TorrentStorage for SlowStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - sleep_from_reader(&self.pread_exact_bufread); - self.underlying.pread_exact(file_id, offset, buf) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - sleep_from_reader(&self.pwrite_all_bufread); - self.underlying.pwrite_all(file_id, offset, buf) - } - - fn remove_file(&self, file_id: usize, filename: &std::path::Path) -> anyhow::Result<()> { - self.underlying.remove_file(file_id, filename) - } - - fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { - self.underlying.ensure_file_length(file_id, length) - } - - fn take(&self) -> anyhow::Result> { - anyhow::bail!("not implemented") - } - - fn remove_directory_if_empty(&self, path: &std::path::Path) -> anyhow::Result<()> { - self.underlying.remove_directory_if_empty(path) - } - - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()> { - self.underlying.init(shared, metadata) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/timing.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/timing.rs deleted file mode 100644 index e71e490b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/timing.rs +++ /dev/null @@ -1,119 +0,0 @@ -/* -A storage middleware that logs the time underlying storage operations took. -*/ - -use crate::{ - ManagedTorrentShared, - storage::{StorageFactory, StorageFactoryExt, TorrentStorage}, - torrent_state::TorrentMetadata, -}; - -#[derive(Clone)] -pub struct TimingStorageFactory { - name: String, - underlying_factory: U, -} - -impl TimingStorageFactory { - pub fn new(name: String, underlying: U) -> Self { - Self { - name, - underlying_factory: underlying, - } - } -} - -impl StorageFactory for TimingStorageFactory { - type Storage = TimingStorage; - - fn create( - &self, - shared: &crate::ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - Ok(TimingStorage { - name: self.name.clone(), - underlying: self.underlying_factory.create(shared, metadata)?, - }) - } - - fn is_type_id(&self, type_id: std::any::TypeId) -> bool { - self.underlying_factory.is_type_id(type_id) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.clone().boxed() - } -} - -pub struct TimingStorage { - name: String, - underlying: U, -} - -macro_rules! timeit { - ($name:expr, $op:expr, $($rest:tt),*) => { - { - let start = std::time::Instant::now(); - let r = $op; - let elapsed = start.elapsed(); - tracing::debug!(name = $name, $($rest),*, elapsed_micros=elapsed.as_micros()); - r - } - }; -} - -impl TorrentStorage for TimingStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - let storage = &self.name; - let len = buf.len(); - timeit!( - "pread_exact", - self.underlying.pread_exact(file_id, offset, buf), - file_id, - offset, - storage, - len - ) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - let storage = &self.name; - let len = buf.len(); - timeit!( - "pwrite_all", - self.underlying.pwrite_all(file_id, offset, buf), - file_id, - offset, - storage, - len - ) - } - - fn remove_file(&self, file_id: usize, filename: &std::path::Path) -> anyhow::Result<()> { - self.underlying.remove_file(file_id, filename) - } - - fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { - self.underlying.ensure_file_length(file_id, length) - } - - fn take(&self) -> anyhow::Result> { - Ok(Box::new(TimingStorage { - underlying: self.underlying.take()?, - name: self.name.clone(), - })) - } - - fn remove_directory_if_empty(&self, path: &std::path::Path) -> anyhow::Result<()> { - self.underlying.remove_directory_if_empty(path) - } - - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()> { - self.underlying.init(shared, metadata) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/write_through_cache.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/write_through_cache.rs deleted file mode 100644 index 11cf401a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/middleware/write_through_cache.rs +++ /dev/null @@ -1,136 +0,0 @@ -/* -A storage middleware that caches pieces in memory, so that subsequent reads (for checksumming) are -free. - -An example, untested and unproven to be useful. -*/ - -use std::num::NonZeroUsize; - -use anyhow::Context; -use librtbit_core::lengths::{Lengths, ValidPieceIndex}; -use lru::LruCache; -use parking_lot::RwLock; - -use crate::{ - FileInfos, ManagedTorrentShared, - storage::{StorageFactory, StorageFactoryExt, TorrentStorage}, - torrent_state::TorrentMetadata, -}; - -#[derive(Clone, Copy)] -pub struct WriteThroughCacheStorageFactory { - max_cache_bytes: u64, - underlying: U, -} - -impl WriteThroughCacheStorageFactory { - pub fn new(max_cache_bytes: u64, underlying: U) -> Self { - Self { - max_cache_bytes, - underlying, - } - } -} - -impl StorageFactory for WriteThroughCacheStorageFactory { - type Storage = WriteThroughCacheStorage; - - fn create( - &self, - shared: &crate::ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - let pieces = self - .max_cache_bytes - .div_ceil(metadata.info.lengths().default_piece_length() as u64) - .try_into()?; - let pieces = NonZeroUsize::new(pieces).context("bug: pieces == 0")?; - let lru = RwLock::new(LruCache::new(pieces)); - Ok(WriteThroughCacheStorage { - lru, - underlying: self.underlying.create(shared, metadata)?, - lengths: *metadata.info.lengths(), - file_infos: metadata.file_infos.clone(), - }) - } - - fn clone_box(&self) -> crate::storage::BoxStorageFactory { - self.clone().boxed() - } -} - -pub struct WriteThroughCacheStorage { - lru: RwLock>>, - lengths: Lengths, - file_infos: FileInfos, - underlying: U, -} - -impl TorrentStorage for WriteThroughCacheStorage { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - let file = self.file_infos.get(file_id).context("wrong file")?; - let current = self - .lengths - .compute_current_piece(offset, file.offset_in_torrent) - .context("wrong piece")?; - let mut g = self.lru.write(); - if let Some(p) = g.get(¤t.id) { - let start = current.piece_offset as usize; - let end = start + buf.len(); - let pbuf = p.get(start..end).context("bugged length")?; - buf.copy_from_slice(pbuf); - return Ok(()); - } - self.underlying.pread_exact(file_id, offset, buf) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - let file = self.file_infos.get(file_id).context("wrong file")?; - let current = self - .lengths - .compute_current_piece(offset, file.offset_in_torrent) - .context("wrong piece")?; - let mut g = self.lru.write(); - let pbuf = g.get_or_insert_mut(current.id, || { - vec![0; self.lengths.piece_length(current.id) as usize].into_boxed_slice() - }); - let start = current.piece_offset as usize; - let end = start + buf.len(); - pbuf.get_mut(start..end) - .context("bugged range")? - .copy_from_slice(buf); - self.underlying.pwrite_all(file_id, offset, buf) - } - - fn remove_file(&self, file_id: usize, filename: &std::path::Path) -> anyhow::Result<()> { - self.underlying.remove_file(file_id, filename) - } - - fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { - self.underlying.ensure_file_length(file_id, length) - } - - fn take(&self) -> anyhow::Result> { - let replacement_cache = LruCache::new(NonZeroUsize::new(1).context("unreachable")?); - let lru = std::mem::replace(&mut *self.lru.write(), replacement_cache); - Ok(Box::new(WriteThroughCacheStorage { - lru: RwLock::new(lru), - underlying: self.underlying.take()?, - lengths: self.lengths, - file_infos: self.file_infos.clone(), - })) - } - - fn remove_directory_if_empty(&self, path: &std::path::Path) -> anyhow::Result<()> { - self.underlying.remove_directory_if_empty(path) - } - - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()> { - self.underlying.init(shared, metadata) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/mod.rs deleted file mode 100644 index 30e3745d..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/storage/mod.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! Storage engine for torrent data. -//! -//! This is deliberately sync, not async by design, for several reasons. -//! -//! Reason 1. Performance: avoiding copying. -//! -//! Torrent files are large so memcpy costs can compound. Tokio FS does all file writes in a thread -//! pool. To do those writes it has a buffer per file. When you call e.g. "write", your request is first -//! memcpy'ed to that buffer, and only then written to the file. -//! -//! On the write path (download), we write straight from the peer's socket buffer into the file. -//! On the read path (upload), we read straight into the peer's socket buffer also. -//! -//! Reason 2. Memory use: memory bloat would be a problem if tokio::fs was used. -//! -//! The said buffers above default to 2MB. We have a lot of files open, so this can compound into a pretty large -//! memory use. -//! -//! Reason 3. Performance: advanced FS APIs. -//! -//! We use positioned vectored writes (pwritev). Tokio doesn't support that. -//! Positioned so that writing can be done to files in parallel without locks. -//! Vectored so that we issue 1 write call for a potentially non-contiguous chunk. - -pub mod filesystem; - -#[cfg(feature = "storage_examples")] -pub mod examples; - -#[cfg(feature = "storage_middleware")] -pub mod middleware; - -use std::{ - any::{Any, TypeId}, - io::IoSlice, - path::Path, -}; - -use librtbit_core::lengths::ValidPieceIndex; - -use crate::torrent_state::{ManagedTorrentShared, TorrentMetadata}; - -pub trait StorageFactory: Send + Sync + Any { - type Storage: TorrentStorage; - - fn create( - &self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result; - fn create_and_init( - &self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - let mut storage = self.create(shared, metadata)?; - storage.init(shared, metadata)?; - Ok(storage) - } - - fn is_type_id(&self, type_id: TypeId) -> bool { - Self::type_id(self) == type_id - } - fn clone_box(&self) -> BoxStorageFactory; -} - -pub type BoxStorageFactory = Box>>; - -pub trait StorageFactoryExt { - fn boxed(self) -> BoxStorageFactory; -} - -impl StorageFactoryExt for SF { - fn boxed(self) -> BoxStorageFactory { - struct Wrapper { - sf: SF, - } - - impl StorageFactory for Wrapper { - type Storage = Box; - - fn create( - &self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - let s = self.sf.create(shared, metadata)?; - Ok(Box::new(s)) - } - - fn is_type_id(&self, type_id: TypeId) -> bool { - self.sf.type_id() == type_id - } - - fn clone_box(&self) -> BoxStorageFactory { - self.sf.clone_box() - } - } - - Box::new(Wrapper { sf: self }) - } -} - -impl StorageFactory for Box { - type Storage = U::Storage; - - fn create( - &self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result { - (**self).create(shared, metadata) - } - - fn clone_box(&self) -> BoxStorageFactory { - (**self).clone_box() - } -} - -pub trait TorrentStorage: Send + Sync { - // Create/open files etc. - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()>; - - /// Given a file_id (which you can get more info from in init_storage() through torrent info) - /// read buf.len() bytes into buf at offset. - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()>; - - /// Given a file_id (which you can get more info from in init_storage() through torrent info) - /// write buf.len() bytes into the file at offset. - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()>; - - fn pwrite_all_vectored( - &self, - file_id: usize, - offset: u64, - bufs: [IoSlice<'_>; 2], - ) -> anyhow::Result { - let mut offset = offset; - let mut size = 0; - - for ioslice in bufs { - self.pwrite_all(file_id, offset, &ioslice)?; - offset += ioslice.len() as u64; - size += ioslice.len(); - } - - Ok(size) - } - - /// Remove a file from the storage. If not supported, or it doesn't matter, just return Ok(()) - fn remove_file(&self, file_id: usize, filename: &Path) -> anyhow::Result<()>; - - fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()>; - - /// E.g. for filesystem backend ensure that the file has a certain length, and grow/shrink as needed. - fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()>; - - /// Replace the current storage with a dummy, and return a new one that should be used instead. - /// This is used to make the underlying object useless when e.g. pausing the torrent. - fn take(&self) -> anyhow::Result>; - - /// Callback called every time a piece has completed and has been validated. - /// Default implementation does nothing, but can be override in trait implementations. - fn on_piece_completed(&self, _piece_index: ValidPieceIndex) -> anyhow::Result<()> { - Ok(()) - } -} - -impl TorrentStorage for Box { - fn pread_exact(&self, file_id: usize, offset: u64, buf: &mut [u8]) -> anyhow::Result<()> { - (**self).pread_exact(file_id, offset, buf) - } - - fn pwrite_all(&self, file_id: usize, offset: u64, buf: &[u8]) -> anyhow::Result<()> { - (**self).pwrite_all(file_id, offset, buf) - } - - fn remove_file(&self, file_id: usize, filename: &Path) -> anyhow::Result<()> { - (**self).remove_file(file_id, filename) - } - - fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { - (**self).ensure_file_length(file_id, length) - } - - fn take(&self) -> anyhow::Result> { - (**self).take() - } - - fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { - (**self).remove_directory_if_empty(path) - } - - fn init( - &mut self, - shared: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> anyhow::Result<()> { - (**self).init(shared, metadata) - } - - fn on_piece_completed(&self, piece_id: ValidPieceIndex) -> anyhow::Result<()> { - (**self).on_piece_completed(piece_id) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/stream_connect.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/stream_connect.rs deleted file mode 100644 index ab9b0e9b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/stream_connect.rs +++ /dev/null @@ -1,326 +0,0 @@ -use std::{net::SocketAddr, sync::Arc, time::Duration}; - -use anyhow::{Context, bail}; -use librqbit_dualstack_sockets::ConnectOpts; -use librqbit_utp::{BindDevice, UtpSocketUdp}; -use serde::Serialize; -use tracing::debug; - -use crate::{ - Error, PeerConnectionOptions, Result, - type_aliases::{BoxAsyncReadVectored, BoxAsyncWrite}, - vectored_traits::AsyncReadVectoredIntoCompat, -}; - -#[derive(Debug, Clone, Copy, Serialize)] -pub enum ConnectionKind { - #[serde(rename = "tcp")] - Tcp, - #[serde(rename = "utp")] - Utp, - #[serde(rename = "socks")] - Socks, -} - -impl std::fmt::Display for ConnectionKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConnectionKind::Tcp => f.write_str("tcp"), - ConnectionKind::Utp => f.write_str("uTP"), - ConnectionKind::Socks => f.write_str("socks"), - } - } -} - -pub struct ConnectionOptions { - // socks5://[username:password@]host:port - // If set, all outgoing connections will go through the proxy over TCP. - pub proxy_url: Option, - // TCP outgoing connections are enabled by default - pub enable_tcp: bool, - pub peer_opts: Option, -} - -impl Default for ConnectionOptions { - fn default() -> Self { - Self { - enable_tcp: true, - proxy_url: None, - peer_opts: None, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct SocksProxyConfig { - pub host: String, - pub port: u16, - pub username_password: Option<(String, String)>, -} - -#[derive(Default, Debug, Clone)] -pub(crate) struct StreamConnectorArgs { - pub enable_tcp: bool, - pub socks_proxy_config: Option, - pub utp_socket: Option>, - pub bind_device: Option, - pub ipv4_only: bool, -} - -impl SocksProxyConfig { - pub fn parse(url: &str) -> anyhow::Result { - let url = ::url::Url::parse(url).context("invalid proxy URL")?; - if url.scheme() != "socks5" { - anyhow::bail!("proxy URL should have socks5 scheme"); - } - let host = url.host_str().context("missing host")?; - let port = url.port().context("missing port")?; - let up = url - .password() - .map(|p| (url.username().to_owned(), p.to_owned())); - Ok(Self { - host: host.to_owned(), - port, - username_password: up, - }) - } - - async fn connect( - &self, - addr: SocketAddr, - ) -> tokio_socks::Result<( - impl tokio::io::AsyncRead + Unpin + 'static, - impl tokio::io::AsyncWrite + Unpin + 'static, - )> { - let proxy_addr = (self.host.as_str(), self.port); - - let stream = if let Some((username, password)) = self.username_password.as_ref() { - tokio_socks::tcp::Socks5Stream::connect_with_password( - proxy_addr, - addr, - username.as_str(), - password.as_str(), - ) - .await? - } else { - tokio_socks::tcp::Socks5Stream::connect(proxy_addr, addr).await? - }; - - Ok(tokio::io::split(stream)) - } -} - -gen_stats!(SingleStatAtomic SingleStatSnapshot, [ - attempts u64, - successes u64, - errors u64 -], []); -gen_stats!(PerFamilyAtomic PerFamilySnapshot, [], [ - v4 SingleStatAtomic SingleStatSnapshot, - v6 SingleStatAtomic SingleStatSnapshot -]); -gen_stats!(ConnectStatsAtomic ConnectStatsSnapshot, [], [ - socks PerFamilyAtomic PerFamilySnapshot, - tcp PerFamilyAtomic PerFamilySnapshot, - utp PerFamilyAtomic PerFamilySnapshot -]); - -#[derive(Debug)] -pub(crate) struct StreamConnector { - proxy_config: Option, - enable_tcp: bool, - bind_device: Option, - utp_socket: Option>, - stats: ConnectStatsAtomic, - ipv4_only: bool, -} - -impl StreamConnector { - pub async fn new(config: StreamConnectorArgs) -> anyhow::Result { - #[allow(clippy::single_match)] - match ( - config.socks_proxy_config.is_some(), - config.enable_tcp, - config.utp_socket.is_some(), - ) { - (false, false, false) => { - bail!("no way to connect to peers, enable TCP, uTP or socks proxy") - } - _ => { - // TODO: maybe validate other combinations. For now there's no way to disable TCP - } - } - - Ok(Self { - proxy_config: config.socks_proxy_config, - enable_tcp: config.enable_tcp, - utp_socket: config.utp_socket, - bind_device: config.bind_device, - stats: Default::default(), - ipv4_only: config.ipv4_only, - }) - } - - fn get_stat(&self, kind: ConnectionKind, is_v6: bool) -> &SingleStatAtomic { - let stat = match kind { - ConnectionKind::Tcp => &self.stats.tcp, - ConnectionKind::Utp => &self.stats.utp, - ConnectionKind::Socks => &self.stats.socks, - }; - if is_v6 { &stat.v6 } else { &stat.v4 } - } - - async fn with_stat( - &self, - kind: ConnectionKind, - is_v6: bool, - fut: impl Future>, - ) -> std::result::Result { - let stat = self.get_stat(kind, is_v6); - stat.attempts(1); - fut.await - .inspect(|_| stat.successes(1)) - .inspect_err(|_| stat.errors(1)) - } - - async fn tcp_connect( - &self, - addr: SocketAddr, - ) -> librqbit_dualstack_sockets::Result { - self.with_stat( - ConnectionKind::Tcp, - addr.is_ipv6(), - librqbit_dualstack_sockets::tcp_connect( - addr, - ConnectOpts { - // Setting source port doesn't work with cloudflare warp on linux - // source_port: self.tcp_source_port, - source_port: None, - bind_device: self.bind_device.as_ref(), - }, - ), - ) - .await - } - - pub fn stats(&self) -> &ConnectStatsAtomic { - &self.stats - } - - pub async fn connect( - &self, - addr: SocketAddr, - ) -> Result<(ConnectionKind, BoxAsyncReadVectored, BoxAsyncWrite)> { - if addr.port() == 0 { - return Err(Error::Anyhow(anyhow::anyhow!( - "invalid peer address (port 0): {}", - addr - ))); - } - - if self.ipv4_only && addr.is_ipv6() { - return Err(Error::Anyhow(anyhow::anyhow!( - "ipv6 disabled, skipping connection to {}", - addr - ))); - } - - if let Some(proxy) = self.proxy_config.as_ref() { - let (r, w) = self - .with_stat(ConnectionKind::Socks, addr.is_ipv6(), proxy.connect(addr)) - .await?; - debug!(?addr, "connected through SOCKS5"); - return Ok(( - ConnectionKind::Socks, - Box::new(r.into_vectored_compat()), - Box::new(w), - )); - } - - // Try to connect over TCP first. If in 1 second we haven't connected, try uTP also (if configured). - // Whoever connects first wins. - let tcp_connect = async { - if !self.enable_tcp { - return Ok(None); - } - let conn = self.tcp_connect(addr).await?; - debug!(?addr, "connected over TCP"); - Ok::<_, librqbit_dualstack_sockets::Error>(Some(conn)) - }; - - let tcp_failed_notify = tokio::sync::Notify::new(); - - let utp_connect = async { - let sock = match self.utp_socket.as_ref() { - Some(sock) => sock, - None => return Ok(None), - }; - - // Give TCP priority as it's more mature and simpler. - if self.enable_tcp { - // wait until either 1 second has passed or TCP failed. - tokio::select! { - _ = tcp_failed_notify.notified() => {}, - _ = tokio::time::sleep(Duration::from_secs(1)) => {} - } - } - - let conn = self - .with_stat(ConnectionKind::Utp, addr.is_ipv6(), sock.connect(addr)) - .await?; - - debug!(?addr, "connected over uTP"); - Ok(Some(conn)) - }; - - tokio::pin!(tcp_connect); - tokio::pin!(utp_connect); - - let mut tcp_err: Option> = None; - let mut utp_err: Option> = None; - - // wait until all fail, or one succeeds. - loop { - if let (Some(tcp), Some(utp)) = (tcp_err.as_mut(), utp_err.as_mut()) { - match (tcp.take(), utp.take()) { - (Some(tcp), Some(utp)) => return Err(Error::Connect { tcp, utp }), - (Some(tcp), None) => return Err(Error::TcpConnect(tcp)), - (None, Some(utp)) => return Err(Error::UtpConnect(utp)), - (None, None) => return Err(Error::ConnectDisabled), - } - } - tokio::select! { - tcp_res = &mut tcp_connect, if tcp_err.is_none() => { - match tcp_res { - Ok(Some(stream)) => { - let (r, w) = stream.into_split(); - return Ok((ConnectionKind::Tcp, Box::new(r), Box::new(w))); - }, - Ok(None) => { - tcp_err = Some(None); - tcp_failed_notify.notify_waiters(); - } - Err(e) => { - tcp_err = Some(Some(e)); - tcp_failed_notify.notify_waiters(); - } - } - }, - utp_res = &mut utp_connect, if utp_err.is_none() => { - match utp_res { - Ok(Some(stream)) => { - let (r, w) = stream.split(); - return Ok((ConnectionKind::Utp, Box::new(r), Box::new(w))); - }, - Ok(None) => { - utp_err = Some(None); - } - Err(e) => { - utp_err = Some(Some(e)); - } - } - }, - }; - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/cancel_init.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/cancel_init.rs deleted file mode 100644 index 5429c974..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/cancel_init.rs +++ /dev/null @@ -1,294 +0,0 @@ -use std::time::Duration; - -use anyhow::Context; -use tempfile::TempDir; -use tokio::time::timeout; -use tracing::info; - -use crate::{ - AddTorrent, AddTorrentOptions, CreateTorrentOptions, Session, SessionOptions, - api::TorrentIdOrHash, - create_torrent, - spawn_utils::BlockingSpawner, - tests::test_util::{create_default_random_dir_with_torrents, setup_test_logging}, - torrent_state::ManagedTorrentState, -}; - -/// Helper to create a session with DHT and listeners disabled (for fast, isolated tests). -async fn create_test_session( - output_dir: &std::path::Path, -) -> anyhow::Result> { - Session::new_with_opts( - output_dir.to_owned(), - SessionOptions { - disable_dht: true, - persistence: None, - disable_local_service_discovery: true, - ..Default::default() - }, - ) - .await - .context("error creating test session") -} - -/// Test that deleting a torrent while it's still initializing (checking files) -/// cancels the init task promptly via the per-torrent cancellation token. -#[tokio::test(flavor = "multi_thread")] -async fn test_delete_cancels_initializing_torrent() -> anyhow::Result<()> { - setup_test_logging(); - - // Create a relatively large torrent so initialization takes a non-trivial amount of time. - let files_dir = - create_default_random_dir_with_torrents(4, 1024 * 1024, Some("test_delete_init")); - let torrent = create_torrent( - files_dir.path(), - CreateTorrentOptions { - name: None, - piece_length: Some(16384), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await?; - - // Use a separate output directory so the torrent has to do a full initial check - // (no existing files to fast-resume from). - let output_dir = TempDir::with_prefix("test_delete_init_out")?; - let session = create_test_session(output_dir.path()).await?; - - let response = session - .add_torrent( - AddTorrent::from_bytes(torrent.as_bytes()?), - Some(AddTorrentOptions { - paused: false, - ..Default::default() - }), - ) - .await?; - - let (id, handle) = match response { - crate::AddTorrentResponse::Added(id, h) => (id, h), - other => anyhow::bail!( - "unexpected response: expected Added, got {:?}", - other.into_handle().is_some() - ), - }; - - // Verify it's in Initializing state. - let is_initializing = handle.with_state(|s| matches!(s, ManagedTorrentState::Initializing(_))); - // It might have already finished init if it was very fast, so we just log it. - info!("torrent is initializing: {is_initializing}"); - - // Delete the torrent. This should cancel the init task and return promptly. - let delete_result = timeout( - Duration::from_secs(5), - session.delete(TorrentIdOrHash::Id(id), false), - ) - .await - .context("delete() timed out - init task was not cancelled")?; - - delete_result.context("delete() returned error")?; - - // Verify the torrent was removed from the session. - assert!(session.get(TorrentIdOrHash::Id(id)).is_none()); - - info!("test_delete_cancels_initializing_torrent passed"); - Ok(()) -} - -/// Test that pausing a torrent during initialization cancels the init task -/// and transitions to a state that allows re-initialization later. -#[tokio::test(flavor = "multi_thread")] -async fn test_pause_during_initialization() -> anyhow::Result<()> { - setup_test_logging(); - - let files_dir = - create_default_random_dir_with_torrents(4, 1024 * 1024, Some("test_pause_init")); - let torrent = create_torrent( - files_dir.path(), - CreateTorrentOptions { - name: None, - piece_length: Some(16384), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await?; - - let output_dir = TempDir::with_prefix("test_pause_init_out")?; - let session = create_test_session(output_dir.path()).await?; - - let response = session - .add_torrent( - AddTorrent::from_bytes(torrent.as_bytes()?), - Some(AddTorrentOptions { - paused: false, - ..Default::default() - }), - ) - .await?; - - let handle = response - .into_handle() - .context("expected a torrent handle")?; - - // Attempt to pause. With our changes, this should succeed even during initialization. - let pause_result = session.pause(&handle).await; - match pause_result { - Ok(()) => { - info!("pause succeeded during initialization"); - // After pause, the torrent should be in Error state - // (with "paused during initialization" message) and paused flag set. - assert!(handle.is_paused(), "torrent should be marked as paused"); - - let state_name = handle.with_state(|s| s.name().to_string()); - // It should be "error" (from the pause-during-init transition) - // or "paused"/"live" if init completed before we paused. - info!("state after pause: {state_name}"); - } - Err(e) => { - // If the torrent already finished initialization, pause would succeed - // from the Live state, or fail if it's already paused. - info!("pause returned error (init may have completed): {e:#}"); - } - } - - info!("test_pause_during_initialization passed"); - Ok(()) -} - -/// Test that the per-torrent cancellation token is properly triggered when -/// delete or forget is called, and that it's a child of the session token. -#[tokio::test(flavor = "multi_thread")] -async fn test_cancellation_token_propagation() -> anyhow::Result<()> { - setup_test_logging(); - - let files_dir = create_default_random_dir_with_torrents(1, 4096, Some("test_cancel_token")); - let torrent = create_torrent( - files_dir.path(), - CreateTorrentOptions { - name: None, - piece_length: Some(1024), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await?; - - let output_dir = TempDir::with_prefix("test_cancel_token_out")?; - let session = create_test_session(output_dir.path()).await?; - - let response = session - .add_torrent( - AddTorrent::from_bytes(torrent.as_bytes()?), - Some(AddTorrentOptions { - paused: true, - ..Default::default() - }), - ) - .await?; - - let (id, handle) = match response { - crate::AddTorrentResponse::Added(id, h) => (id, h), - other => anyhow::bail!("unexpected response: {:?}", other.into_handle().is_some()), - }; - - // Wait for initialization to complete (it's paused, so it will init then stay paused). - timeout(Duration::from_secs(10), handle.wait_until_initialized()) - .await - .context("timed out waiting for initialization")? - .context("error during initialization")?; - - // Capture the per-torrent cancellation token before deletion. - let torrent_token = handle.shared.child_token(); - - // Verify the torrent token is NOT cancelled before delete. - assert!( - !torrent_token.is_cancelled(), - "torrent token should not be cancelled before delete" - ); - - // Delete the torrent. - session - .delete(TorrentIdOrHash::Id(id), false) - .await - .context("error deleting torrent")?; - - // After delete, the per-torrent token should be cancelled. - // The child token we captured above should also be cancelled - // (since we cancelled its parent). - assert!( - torrent_token.is_cancelled(), - "torrent cancellation token should be cancelled after delete" - ); - - // Verify the torrent is no longer in the session. - assert!(session.get(TorrentIdOrHash::Id(id)).is_none()); - - // Also verify session-level token is NOT cancelled (only the torrent was removed). - assert!( - !session.cancellation_token().is_cancelled(), - "session token should not be cancelled by single torrent delete" - ); - - info!("test_cancellation_token_propagation passed"); - Ok(()) -} - -/// Test that session shutdown cancels all per-torrent tokens (since they are children -/// of the session token). -#[tokio::test(flavor = "multi_thread")] -async fn test_session_shutdown_cancels_torrent_tokens() -> anyhow::Result<()> { - setup_test_logging(); - - let files_dir = create_default_random_dir_with_torrents(1, 4096, Some("test_session_shutdown")); - let torrent = create_torrent( - files_dir.path(), - CreateTorrentOptions { - name: None, - piece_length: Some(1024), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await?; - - let output_dir = TempDir::with_prefix("test_session_shutdown_out")?; - let session = create_test_session(output_dir.path()).await?; - - let response = session - .add_torrent( - AddTorrent::from_bytes(torrent.as_bytes()?), - Some(AddTorrentOptions { - paused: true, - ..Default::default() - }), - ) - .await?; - - let handle = response.into_handle().context("expected torrent handle")?; - - timeout(Duration::from_secs(10), handle.wait_until_initialized()) - .await - .context("timed out waiting for init")? - .context("init error")?; - - // Capture a child of the per-torrent token. - let torrent_child_token = handle.shared.child_token(); - assert!(!torrent_child_token.is_cancelled()); - - // Stop the session. - session.stop().await; - - // The session token should be cancelled. - assert!(session.cancellation_token().is_cancelled()); - - // The per-torrent token (child of session) should also be cancelled. - assert!( - torrent_child_token.is_cancelled(), - "per-torrent token should be cancelled after session shutdown" - ); - - info!("test_session_shutdown_cancels_torrent_tokens passed"); - Ok(()) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e.rs deleted file mode 100644 index 2408054c..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e.rs +++ /dev/null @@ -1,387 +0,0 @@ -use std::{net::Ipv4Addr, time::Duration}; - -use anyhow::{Context, bail}; -use librtbit_core::magnet::Magnet; -use rand::Rng; -use tokio::{ - spawn, - time::{interval, timeout}, -}; -use tracing::{Instrument, error, error_span, info}; - -use crate::{ - AddTorrentOptions, AddTorrentResponse, ConnectionOptions, ListenerMode, Session, - SessionOptions, SessionPersistenceConfig, create_torrent, - listen::ListenerOptions, - spawn_utils::BlockingSpawner, - tests::test_util::{ - DropChecks, TestPeerMetadata, create_default_random_dir_with_torrents, setup_test_logging, - wait_until_i_am_the_last_task, - }, -}; - -#[tokio::test(flavor = "multi_thread")] -async fn test_e2e_download_tcp() { - _test_e2e_download_timeout_and_cleanups(ListenerMode::TcpOnly).await -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_e2e_download_utp() { - _test_e2e_download_timeout_and_cleanups(ListenerMode::UtpOnly).await -} - -async fn _test_e2e_download_timeout_and_cleanups(mode: ListenerMode) { - let timeout = std::env::var("E2E_TIMEOUT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(180); - - let drop_checks = DropChecks::default(); - tokio::time::timeout( - Duration::from_secs(timeout), - _test_e2e_download(mode, &drop_checks), - ) - .await - .context("test_e2e_download timed out") - .unwrap(); - - // Wait to ensure everything is dropped. - wait_until_i_am_the_last_task().await.unwrap(); - - drop_checks.check().unwrap(); -} - -async fn _test_e2e_download(mode: ListenerMode, drop_checks: &DropChecks) { - setup_test_logging(); - match crate::try_increase_nofile_limit() { - Ok(limit) => info!(limit, "increased ulimit"), - Err(e) => error!(error=?e, "error increasing ulimit"), - }; - - // 1. Create a torrent - // Ideally (for a more complicated test) with N files, and at least N pieces that span 2 files. - - let piece_length: u32 = 16384 * 2; - let file_length: usize = 8 * 1000 * 1000; // uneven files will make pieces cross file boundaries - - // Not setting this too high as 64 causes too many open files on osx on github runners. - let num_files: usize = 8; - - let tempdir = - create_default_random_dir_with_torrents(num_files, file_length, Some("rtbit_e2e")); - let torrent_file = create_torrent( - dbg!(tempdir.path()), - crate::CreateTorrentOptions { - piece_length: Some(piece_length), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await - .unwrap(); - - let num_servers = std::env::var("E2E_NUM_SERVERS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(32u8); - - let torrent_file_bytes = torrent_file.as_bytes().unwrap(); - let mut futs = Vec::new(); - - // 2. Start N servers that are serving that torrent, and return their IP:port combos. - // Disable DHT on each. - for i in 0..num_servers { - let torrent_file_bytes = torrent_file_bytes.clone(); - let tempdir = tempdir.path().to_owned(); - let drop_checks = drop_checks.clone(); - let fut = spawn( - async move { - let peer_id = TestPeerMetadata { - server_id: i, - max_random_sleep_ms: rand::rng().random_range(0u8..16), - } - .as_peer_id(); - let listen_port = 15100u16 + i as u16; - let session = crate::Session::new_with_opts( - std::env::temp_dir().join("does_not_exist"), - SessionOptions { - disable_dht: true, - peer_id: Some(peer_id), - listen: Some(ListenerOptions { - mode, - listen_addr: (Ipv4Addr::LOCALHOST, listen_port).into(), - ..Default::default() - }), - root_span: Some(error_span!(parent: None, "server", id = i)), - disable_local_service_discovery: true, - ..Default::default() - }, - ) - .await - .context("error starting session")?; - - drop_checks.add(&session, format!("server session {i}")); - - info!("started session"); - - let handle = session - .add_torrent( - crate::AddTorrent::TorrentFileBytes(torrent_file_bytes), - Some(AddTorrentOptions { - overwrite: true, - output_folder: Some(tempdir.to_str().unwrap().to_owned()), - ..Default::default() - }), - ) - .await - .context("error adding torrent")?; - let h = handle.into_handle().context("into_handle()")?; - - drop_checks.add(&h.shared, format!("server {i} torrent shared handle")); - - let mut interval = interval(Duration::from_millis(100)); - - info!("added torrent"); - loop { - interval.tick().await; - let is_live = h - .with_state(|s| match s { - crate::ManagedTorrentState::Initializing(_) => Ok(false), - crate::ManagedTorrentState::Live(l) => { - if !l.is_finished() { - bail!("torrent went live, but expected it to be finished"); - } - Ok(true) - } - crate::ManagedTorrentState::Error(e) => bail!("error: {e:#}"), - _ => bail!("broken state"), - }) - .context("error checking for torrent liveness")?; - if is_live { - break; - } - } - info!("torrent is live"); - let addr = session - .listen_addr() - .context("expected listen_addr to be set")?; - Ok::<_, anyhow::Error>((session, addr)) - } - .instrument(error_span!("server", id = i)), - ); - futs.push(timeout(Duration::from_secs(30), fut)); - } - - let mut peers = Vec::new(); - - // This is around just not to drop. - let mut _servers = Vec::new(); - for (id, peer) in futures::future::join_all(futs) - .await - .into_iter() - .enumerate() - { - let (server, peer) = peer - .with_context(|| format!("join error, server={id}")) - .unwrap() - .with_context(|| format!("timeout, server={id}")) - .unwrap() - .with_context(|| format!("server couldn't start, server={id}")) - .unwrap(); - peers.push(peer); - _servers.push(server); - } - - info!("started all servers, starting client"); - - let client_iters = std::env::var("E2E_CLIENT_ITERS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(1usize); - - let magnet = Magnet::from_id20(torrent_file.info_hash(), Vec::new(), None).to_string(); - - // 3. Start a client with the initial peers, and download the file. - for _ in 0..client_iters { - let root = tempfile::TempDir::with_prefix("rtbit_e2e_client").unwrap(); - let outdir = root.path().join("out"); - let session_persistence = root.path().join("session"); - let session = Session::new_with_opts( - outdir.to_owned(), - SessionOptions { - disable_dht: true, - disable_dht_persistence: true, - dht_config: None, - persistence: Some(SessionPersistenceConfig::Json { - folder: Some(session_persistence), - }), - listen: if mode.utp_enabled() { - Some(ListenerOptions { - mode: ListenerMode::UtpOnly, - listen_addr: ([127, 0, 0, 1], 15099).into(), - ..Default::default() - }) - } else { - None - }, - connect: Some(ConnectionOptions { - enable_tcp: mode.tcp_enabled(), - ..Default::default() - }), - fastresume: true, - disable_local_service_discovery: true, - root_span: Some(error_span!("client")), - ..Default::default() - }, - ) - .await - .unwrap(); - drop_checks.add(&session, "client session"); - - info!("started client session"); - - let (id, handle) = { - let r = session - .add_torrent( - crate::AddTorrent::Url((&magnet).into()), - Some(AddTorrentOptions { - initial_peers: Some(peers.clone()), - // only_files: Some(vec![0]), - overwrite: false, - ..Default::default() - }), - ) - .await - .unwrap(); - - match r { - AddTorrentResponse::AlreadyManaged(_, _) => todo!(), - AddTorrentResponse::ListOnly(_) => todo!(), - AddTorrentResponse::Added(id, h) => (id, h), - } - }; - - info!("added handle"); - - { - let stats_printer = { - let handle = handle.clone(); - async move { - let mut interval = interval(Duration::from_millis(100)); - - loop { - interval.tick().await; - let stats = handle.stats(); - let live = match &stats.live { - Some(live) => live, - None => continue, - }; - let pstats = &live.snapshot.peer_stats; - - info!( - progress_percent = - format!("{}", stats.progress_percent_human_readable()), - peers = format!("{:?}", pstats), - ); - } - } - } - .instrument(error_span!("stats_printer")); - - let timeout = timeout(Duration::from_secs(180), handle.wait_until_completed()); - - tokio::pin!(stats_printer); - tokio::pin!(timeout); - - let mut stats_finished = false; - loop { - tokio::select! { - r = &mut timeout => { - r.unwrap().unwrap(); - break; - } - _ = &mut stats_printer, if !stats_finished => { - stats_finished = true; - } - } - } - } - - info!("handle is completed"); - tokio::time::timeout(Duration::from_secs(5), session.delete(id.into(), false)) - .await - .context("timeout deleting torrent") - .unwrap() - .context("error deleting") - .unwrap(); - - info!("deleted handle"); - - // 4. After downloading, recheck its integrity. - let handle = session - .add_torrent( - crate::AddTorrent::TorrentFileBytes(torrent_file_bytes.clone()), - Some(AddTorrentOptions { - paused: true, - overwrite: true, - ..Default::default() - }), - ) - .await - .unwrap() - .into_handle() - .unwrap(); - - info!("re-added handle"); - - timeout(Duration::from_secs(60), async { - let mut interval = interval(Duration::from_millis(100)); - loop { - interval.tick().await; - let b = handle - .with_state(|s| match s { - crate::ManagedTorrentState::Initializing(_) => Ok(false), - crate::ManagedTorrentState::Paused(p) => { - let needed = p.chunk_tracker.get_hns().needed_bytes; - if needed == 0 { - Ok(true) - } else { - info!(needed_bytes = needed, "still verifying pieces"); - Ok(false) - } - } - _ => bail!("bugged state"), - }) - .unwrap(); - if b { - break; - } - } - }) - .await - .context("timed out waiting for piece verification to complete (needed_bytes != 0)") - .unwrap(); - - tokio::time::timeout( - Duration::from_secs(5), - session.delete(handle.id().into(), true), - ) - .await - .context("timeout") - .unwrap() - .context("error deleting") - .unwrap(); - - // Ensure the files were deleted from the filesystem. - let d = std::fs::read_dir(&outdir) - .context("read_dir outdir") - .unwrap(); - assert_eq!( - d.into_iter().count(), - 0, - "{outdir:?} was not empty after deletion" - ); - - info!("all good"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e_another_local_client.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e_another_local_client.rs deleted file mode 100644 index fb50f6ec..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e_another_local_client.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::{ - net::{IpAddr, Ipv4Addr, SocketAddr}, - time::Duration, -}; - -use bytes::Bytes; -use tracing::info; - -use crate::{ - AddTorrentOptions, ConnectionOptions, Session, SessionOptions, listen::ListenerOptions, - tests::test_util::setup_test_logging, -}; - -// Create this from librqbit_utp: cargo run --release --example create_canary_file /tmp/canary_4096m 4096 -const TORRENT_FILENAME: &str = "/tmp/canary.torrent"; - -// Where to download -const OUTPUT_FOLDER: &str = "/tmp/utptest"; - -// It's hard to find the binary in target/.../deps/librtbit*, so symlink itself -// here for easy profiling. -#[cfg(unix)] -const BINARY_SYMLINK: &str = "/tmp/rtest"; - -const DEFAULT_LISTEN_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 57318); - -// Serve the above file from the other client. -const DEFAULT_OTHER_CLIENT_ADDR: SocketAddr = - SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST), 27312); - -fn parse_listen_and_client_addr() -> (SocketAddr, SocketAddr) { - let listen = - std::env::var("E2E_LISTEN_ADDR").map_or(DEFAULT_LISTEN_ADDR, |v| v.parse().unwrap()); - let client = - std::env::var("E2E_CLIENT_ADDR").map_or(DEFAULT_OTHER_CLIENT_ADDR, |v| v.parse().unwrap()); - (listen, client) -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_utp_with_another_client() { - let (listen_addr, client_addr) = parse_listen_and_client_addr(); - test_with_another_client( - crate::SessionOptions { - disable_dht: true, - persistence: None, - listen: Some(ListenerOptions { - mode: crate::listen::ListenerMode::UtpOnly, - listen_addr, - ..Default::default() - }), - connect: Some(ConnectionOptions { - enable_tcp: false, - ..Default::default() - }), - ..Default::default() - }, - client_addr, - ) - .await -} - -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_tcp_with_another_client() { - let (listen_addr, client_addr) = parse_listen_and_client_addr(); - test_with_another_client( - crate::SessionOptions { - disable_dht: true, - persistence: None, - listen: Some(ListenerOptions { - mode: crate::listen::ListenerMode::TcpOnly, - listen_addr, - ..Default::default() - }), - ..Default::default() - }, - client_addr, - ) - .await -} - -// A test to download a canary file from another torrent client on 127.0.0.1:27312. -// Disabled, uncomment if developing / testing / benchmarking. -// -// The canary file is created from librqbit_utp, then served from the other torrent client: -// cargo run --release --example create_canary_file /tmp/canary_4096m 4096 -async fn test_with_another_client(sopts: SessionOptions, addr: SocketAddr) { - #[cfg(unix)] - { - let test_filename = { - let f = std::path::PathBuf::from(std::env::args().next().unwrap()); - f.read_link().unwrap_or(f) - }; - if std::fs::exists(BINARY_SYMLINK).unwrap() { - std::fs::remove_file(BINARY_SYMLINK).unwrap(); - } - std::os::unix::fs::symlink(test_filename, BINARY_SYMLINK).unwrap(); - } - - setup_test_logging(); - - let t = std::fs::read(TORRENT_FILENAME).unwrap(); - - let session = Session::new_with_opts(OUTPUT_FOLDER.into(), sopts) - .await - .unwrap(); - - let handle = session - .add_torrent( - crate::AddTorrent::TorrentFileBytes(Bytes::from(t)), - Some(AddTorrentOptions { - overwrite: true, - initial_peers: Some(vec![addr]), - ..Default::default() - }), - ) - .await - .unwrap() - .into_handle() - .unwrap(); - - // Print stats periodically. - tokio::spawn({ - let handle = handle.clone(); - async move { - loop { - tokio::time::sleep(Duration::from_millis(100)).await; - let stats = handle.stats(); - info!("{stats:}"); - } - } - }); - - handle.wait_until_completed().await.unwrap(); - - // wait for all final FIN-ACKs to be sent - tokio::time::sleep(Duration::from_millis(100)).await; -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e_stream.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e_stream.rs deleted file mode 100644 index ae3d7a7a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/e2e_stream.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::{net::Ipv4Addr, time::Duration}; - -use anyhow::Context; -use tempfile::TempDir; -use tokio::{io::AsyncReadExt, time::timeout}; -use tracing::info; - -use crate::{ - AddTorrent, CreateTorrentOptions, Session, create_torrent, - spawn_utils::BlockingSpawner, - tests::test_util::{TestPeerMetadata, setup_test_logging}, -}; - -use super::test_util::create_default_random_dir_with_torrents; - -async fn e2e_stream() -> anyhow::Result<()> { - setup_test_logging(); - let files = create_default_random_dir_with_torrents(1, 8192, Some("test_e2e_stream")); - let torrent = create_torrent( - files.path(), - CreateTorrentOptions { - name: None, - piece_length: Some(1024), - ..Default::default() - }, - &BlockingSpawner::new(1), - ) - .await?; - - let orig_content = std::fs::read(files.path().join("0.data")).unwrap(); - let server_session = Session::new_with_opts( - files.path().into(), - crate::SessionOptions { - disable_dht: true, - peer_id: Some(TestPeerMetadata::good().as_peer_id()), - persistence: None, - listen: Some(crate::listen::ListenerOptions { - listen_addr: (Ipv4Addr::LOCALHOST, 16001).into(), - ..Default::default() - }), - ..Default::default() - }, - ) - .await - .context("error creating server session")?; - - info!("created server session"); - - timeout( - Duration::from_secs(5), - server_session - .add_torrent( - AddTorrent::from_bytes(torrent.as_bytes()?), - Some(crate::AddTorrentOptions { - paused: false, - output_folder: Some(files.path().to_str().unwrap().to_owned()), - overwrite: true, - ..Default::default() - }), - ) - .await? - .into_handle() - .unwrap() - .wait_until_completed(), - ) - .await? - .context("error adding torrent")?; - - info!("server torrent was completed"); - - let peer = server_session - .listen_addr() - .context("expected listen_addr to be set")?; - - let client_dir = TempDir::with_prefix("test_e2e_stream_client")?; - - let client_session = Session::new_with_opts( - client_dir.path().into(), - crate::SessionOptions { - disable_dht: true, - persistence: None, - peer_id: Some(TestPeerMetadata::good().as_peer_id()), - ..Default::default() - }, - ) - .await?; - - info!("created client session"); - - let client_handle = client_session - .add_torrent( - AddTorrent::from_bytes(torrent.as_bytes()?), - Some(crate::AddTorrentOptions { - paused: false, - initial_peers: Some(vec![peer]), - ..Default::default() - }), - ) - .await? - .into_handle() - .unwrap(); - - client_handle.wait_until_initialized().await?; - - info!("client torrent initialized, starting stream"); - - let mut stream = client_handle.stream(0).await?; - let mut buf = Vec::::with_capacity(8192); - stream.read_to_end(&mut buf).await?; - - if buf != orig_content { - panic!("contents differ") - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_e2e_stream() -> anyhow::Result<()> { - timeout(Duration::from_secs(10), e2e_stream()).await? -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/mod.rs deleted file mode 100644 index 1b578902..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod cancel_init; -mod e2e; -mod e2e_another_local_client; -mod e2e_stream; -pub mod test_util; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/test_util.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/test_util.rs deleted file mode 100644 index ba6b7813..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tests/test_util.rs +++ /dev/null @@ -1,218 +0,0 @@ -use std::{ - io::Write, - path::Path, - sync::{Arc, Weak}, - time::Duration, -}; - -use anyhow::bail; -use librtbit_core::{Id20, crate_version, peer_id::generate_azereus_style}; -use parking_lot::RwLock; -use rand::{Rng, RngCore, SeedableRng, rng}; -use tempfile::TempDir; -use tracing::{info, trace}; - -pub fn setup_test_logging() { - if std::env::var("RUST_LOG").is_err() { - unsafe { std::env::set_var("RUST_LOG", "info") }; - } - let _ = tracing_subscriber::fmt::try_init(); -} - -pub fn create_new_file_with_random_content(path: &Path, mut size: usize) { - let mut file = std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(path) - .unwrap(); - - trace!(?path, "creating temp file"); - - const BUF_SIZE: usize = 8192 * 16; - let mut rng = rand::rngs::SmallRng::from_os_rng(); - let mut write_buf = [0; BUF_SIZE]; - while size > 0 { - rng.fill_bytes(&mut write_buf[..]); - let written = file.write(&write_buf[..size.min(BUF_SIZE)]).unwrap(); - size -= written; - } -} - -pub fn create_default_random_dir_with_torrents( - num_files: usize, - file_size: usize, - tempdir_prefix: Option<&str>, -) -> TempDir { - let dir = TempDir::with_prefix(tempdir_prefix.unwrap_or("rtbit_test")).unwrap(); - info!(path=?dir.path(), "created tempdir"); - for f in 0..num_files { - create_new_file_with_random_content(&dir.path().join(format!("{f}.data")), file_size); - } - dir -} - -#[derive(Debug)] -pub struct TestPeerMetadata { - pub server_id: u8, - pub max_random_sleep_ms: u8, -} - -impl TestPeerMetadata { - pub fn good() -> Self { - Self { - server_id: 0, - max_random_sleep_ms: 0, - } - } - - pub fn as_peer_id(&self) -> Id20 { - let mut peer_id = generate_azereus_style(*b"rQ", crate_version!()); - peer_id.0[15..19].copy_from_slice(b"test"); - rng().fill(&mut peer_id.0); - peer_id.0[14] = self.server_id; - peer_id.0[13] = self.max_random_sleep_ms; - peer_id - } - - pub fn from_peer_id(peer_id: Id20) -> Self { - if &peer_id.0[15..19] != b"test" { - return Self::good(); - } - Self { - server_id: peer_id.0[14], - max_random_sleep_ms: peer_id.0[13], - } - } - - pub fn disconnect_probability(&self) -> f64 { - if self.server_id % 2 == 1 { - return 0.05f64; - } - 0f64 - } - - pub fn bad_data_probability(&self) -> f64 { - if self.server_id % 2 == 1 { - return 0.05f64; - } - 0f64 - } -} - -#[cfg(feature = "http-api")] -async fn debug_server() -> anyhow::Result<()> { - use anyhow::Context; - use axum::{Router, response::IntoResponse, routing::get}; - async fn backtraces() -> impl IntoResponse { - #[cfg(feature = "async-bt")] - { - async_backtrace::taskdump_tree(true) - } - #[cfg(not(feature = "async-bt"))] - { - use crate::ApiError; - ApiError::from(anyhow::anyhow!( - "backtraces not enabled, enable async-bt feature" - )) - } - } - - let app = Router::new().route("/backtrace", get(backtraces)); - let app = app.into_make_service(); - - let addr = "127.0.0.1:3032"; - - info!(%addr, "starting HTTP server"); - - use tokio::net::TcpListener; - - let listener = TcpListener::bind(addr) - .await - .with_context(|| format!("error binding to {addr}"))?; - axum::serve(listener, app).await?; - Ok(()) -} - -#[cfg(not(feature = "http-api"))] -async fn debug_server() -> anyhow::Result<()> { - Ok(()) -} - -#[allow(dead_code)] -pub fn spawn_debug_server() -> tokio::task::JoinHandle> { - tokio::spawn(debug_server()) -} - -pub trait DropPlaceholder: Send + Sync {} -impl DropPlaceholder for T {} - -struct DropCheck { - obj: Weak, - name: String, -} - -#[derive(Default, Clone)] -pub struct DropChecks(Arc>>); - -impl DropChecks { - pub fn add>(&self, obj: &Arc, name: S) { - let weak = Arc::downgrade(obj); - self.0.write().push(DropCheck { - obj: weak as Weak, - name: name.into(), - }) - } - - pub fn check(&self) -> anyhow::Result<()> { - let mut still_running = Vec::new(); - for dc in self.0.read().iter() { - if dc.obj.upgrade().is_some() { - still_running.push(dc.name.clone()) - } - } - if !still_running.is_empty() { - anyhow::bail!( - "still existing objects that were supposed to be dropped: {still_running:#?}" - ) - } - Ok(()) - } -} - -pub async fn wait_until( - mut cond: impl FnMut() -> anyhow::Result<()>, - timeout: Duration, -) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(Duration::from_millis(10)); - let mut last_err: Option = None; - let res = tokio::time::timeout(timeout, async { - loop { - interval.tick().await; - match cond() { - Ok(()) => return Ok::<_, anyhow::Error>(()), - Err(e) => last_err = Some(e), - } - } - }) - .await; - if res.is_err() { - bail!("wait_until timeout: last result = {last_err:?}") - } - Ok(()) -} - -pub async fn wait_until_i_am_the_last_task() -> anyhow::Result<()> { - let metrics = tokio::runtime::Handle::current().metrics(); - wait_until( - || { - let num_alive = metrics.num_alive_tasks(); - if num_alive != 0 { - bail!("metrics.num_alive_tasks() = {num_alive}, expected 0") - } - Ok(()) - }, - // This needs to be higher than the timeout the tasks print "still running" - Duration::from_secs(15), - ) - .await -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/initializing.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/initializing.rs deleted file mode 100644 index 3324450a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/initializing.rs +++ /dev/null @@ -1,315 +0,0 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, AtomicU64, Ordering}, - }, - time::Instant, -}; - -use anyhow::Context; - -use itertools::Itertools; -use rand::Rng; -use size_format::SizeFormatterBinary as SF; -use tokio_util::sync::CancellationToken; -use tracing::{info, trace, warn}; - -use crate::{ - api::TorrentIdOrHash, - bitv::BitV, - bitv_factory::BitVFactory, - chunk_tracker::{ChunkTracker, compute_selected_pieces}, - file_ops::FileOps, - type_aliases::{BF, FileStorage}, -}; - -use super::{ManagedTorrentShared, TorrentMetadata, paused::TorrentStatePaused}; - -pub struct TorrentStateInitializing { - pub(crate) files: FileStorage, - pub(crate) shared: Arc, - pub(crate) metadata: Arc, - pub(crate) only_files: Option>, - pub(crate) checked_bytes: AtomicU64, - /// True while the torrent is waiting for a concurrent init semaphore slot. - pub(crate) queued_for_init: AtomicBool, - previously_errored: bool, -} - -impl TorrentStateInitializing { - pub fn new( - shared: Arc, - metadata: Arc, - only_files: Option>, - files: FileStorage, - previously_errored: bool, - ) -> Self { - Self { - shared, - metadata, - only_files, - files, - checked_bytes: AtomicU64::new(0), - queued_for_init: AtomicBool::new(false), - previously_errored, - } - } - - pub fn get_checked_bytes(&self) -> u64 { - self.checked_bytes - .load(std::sync::atomic::Ordering::Relaxed) - } - - /// Returns true if this torrent is waiting for a concurrent init slot. - pub fn is_queued_for_init(&self) -> bool { - self.queued_for_init.load(Ordering::Relaxed) - } - - async fn validate_fastresume( - &self, - bitv_factory: &dyn BitVFactory, - have_pieces: Option>, - cancellation_token: &CancellationToken, - ) -> anyhow::Result>> { - let hp = match have_pieces { - Some(hp) => hp, - None => return Ok(None), - }; - let actual = hp.as_bytes().len(); - let expected = self.metadata.lengths().piece_bitfield_bytes(); - if actual != expected { - warn!( - actual, - expected, - "the bitfield loaded isn't of correct length, ignoring it, will do full check" - ); - return Ok(None); - } - - let token = cancellation_token.clone(); - let result: anyhow::Result = self - .shared - .spawner - .block_in_place_with_semaphore(|| { - let fo = crate::file_ops::FileOps::new( - &self.metadata.info, - &self.files, - &self.metadata.file_infos, - ); - - use rand::seq::SliceRandom; - - let mut to_validate = BF::from_boxed_slice( - vec![0u8; self.metadata.lengths().piece_bitfield_bytes()].into_boxed_slice(), - ); - let mut queue = hp.as_slice().to_owned(); - - // Validate at least one piece from each file, if we claim we have it. - for fi in self.metadata.file_infos.iter() { - let prange = fi.piece_range_usize(); - let offset = prange.start; - for piece_id in hp - .as_slice() - .get(fi.piece_range_usize()) - .into_iter() - .flat_map(|s| s.iter_ones()) - .map(|pid| pid + offset) - .take(1) - { - to_validate.set(piece_id, true); - queue.set(piece_id, false); - } - } - - // For all the remaining pieces we claim we have, validate them with decreasing probability. - // The denominator cap is configurable via session options. - let max_denom: Option = self - .shared - .session - .upgrade() - .and_then(|s| s.fastresume_validation_denom) - .or(Some(50)); // default: cap at 50 - - if let Some(cap) = max_denom - && cap > 0 - { - let mut queue = queue.iter_ones().collect_vec(); - queue.shuffle(&mut rand::rng()); - for (tmp_id, piece_id) in queue.into_iter().enumerate() { - let denom: u32 = (tmp_id + 1).min(cap as usize).try_into().unwrap(); - if rand::rng().random_ratio(1, denom) { - to_validate.set(piece_id, true); - } - } - } - // max_denom == Some(0): skip probabilistic validation entirely, - // only the mandatory one-per-file pieces above will be checked - - let to_validate_count = to_validate.count_ones(); - for (id, piece_id) in to_validate - .iter_ones() - .filter_map(|id| { - self.metadata - .lengths() - .validate_piece_index(id.try_into().ok()?) - }) - .enumerate() - { - if token.is_cancelled() { - anyhow::bail!("fastresume validation cancelled"); - } - - if fo.check_piece(piece_id).is_err() { - return Ok(true); - } - - #[allow(clippy::cast_possible_truncation)] - let progress = (self.metadata.lengths().total_length() as f64 - / to_validate_count as f64 - * (id + 1) as f64) as u64; - let progress = progress.min(self.metadata.lengths().total_length()); - self.checked_bytes.store(progress, Ordering::Relaxed); - } - - Ok(false) - }) - .await; - - let is_broken = result?; - - if is_broken { - warn!( - id = ?self.shared.id, - info_hash = ?self.shared.info_hash, - "data corrupted, ignoring fastresume data" - ); - if let Err(e) = bitv_factory.clear(self.shared.id.into()).await { - warn!(id=?self.shared.id, info_hash = ?self.shared.info_hash, "error clearing bitfield: {e:#}"); - } - self.checked_bytes.store(0, Ordering::Relaxed); - return Ok(None); - } - - Ok(Some(hp)) - } - - pub async fn check(&self) -> anyhow::Result { - let cancellation_token = self.shared.child_token(); - let id: TorrentIdOrHash = self.shared.info_hash.into(); - let bitv_factory = self - .shared - .session - .upgrade() - .context("session is dead")? - .bitv_factory - .clone(); - let have_pieces = if self.previously_errored { - if let Err(e) = bitv_factory.clear(id).await { - warn!(id=?self.shared.id, info_hash = ?self.shared.info_hash, error=?e, "error clearing bitfield"); - } - None - } else { - bitv_factory - .load(id) - .await - .context("error loading have_pieces")? - }; - - let have_pieces = self - .validate_fastresume(&*bitv_factory, have_pieces, &cancellation_token) - .await?; - - let have_pieces = match have_pieces { - Some(h) => h, - None => { - info!("Doing initial checksum validation, this might take a while..."); - let token = cancellation_token.clone(); - let have_pieces = self - .shared - .spawner - .block_in_place_with_semaphore(|| { - FileOps::new(&self.metadata.info, &self.files, &self.metadata.file_infos) - .initial_check(&self.checked_bytes, Some(&token)) - }) - .await?; - bitv_factory - .store_initial_check(id, have_pieces) - .await - .context("error storing initial check bitfield")? - } - }; - - let selected_pieces = compute_selected_pieces( - self.metadata.lengths(), - |idx| { - self.only_files - .as_ref() - .map(|o| o.contains(&idx)) - .unwrap_or(true) - }, - &self.metadata.file_infos, - ); - - let chunk_tracker = ChunkTracker::new( - have_pieces.into_dyn(), - selected_pieces, - *self.metadata.lengths(), - &self.metadata.file_infos, - ) - .context("error creating chunk tracker")?; - - let hns = chunk_tracker.get_hns(); - - info!( - torrent=?self.shared.id, - "Initial check results: have {}, needed {}, total selected {}", - SF::new(hns.have_bytes), - SF::new(hns.needed_bytes), - SF::new(hns.selected_bytes) - ); - - // Ensure file lengths are correct, and reopen read-only. - self.shared - .spawner - .block_in_place_with_semaphore(|| { - for (idx, fi) in self.metadata.file_infos.iter().enumerate() { - if self - .only_files - .as_ref() - .map(|v| v.contains(&idx)) - .unwrap_or(true) - { - let now = Instant::now(); - if fi.attrs.padding { - continue; - } - if let Err(err) = self.files.ensure_file_length(idx, fi.len) { - warn!( - id=?self.shared.id, info_hash = ?self.shared.info_hash, - "Error setting length for file {:?} to {}: {:#?}", - fi.relative_filename, fi.len, err - ); - } else { - trace!( - "Set length for file {:?} to {} in {:?}", - fi.relative_filename, - SF::new(fi.len), - now.elapsed() - ); - } - } - } - Ok::<_, anyhow::Error>(()) - }) - .await?; - - let paused = TorrentStatePaused { - shared: self.shared.clone(), - metadata: self.metadata.clone(), - files: self.files.take()?, - chunk_tracker, - streams: Arc::new(Default::default()), - }; - Ok(paused) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/STATE.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/STATE.md deleted file mode 100644 index 9b85e484..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/STATE.md +++ /dev/null @@ -1,230 +0,0 @@ -# Live Torrent State Architecture - -This document describes the shared state used during live torrent downloading and the invariants that are maintained. - -## Key Data Structures - -### 1. `PieceTracker` (in `piece_tracker.rs`) - -Coordinates piece download state by wrapping `ChunkTracker` and `inflight_pieces`. Lives in `TorrentStateLocked`. - -```rust -pub struct PieceTracker { - chunks: ChunkTracker, - inflight: HashMap, -} - -pub struct InflightPiece { - pub peer: PeerHandle, // Which peer "owns" this piece - pub started: Instant, // When download started (for steal threshold) -} -``` - -Key methods that maintain invariants: -- `acquire_piece()` - Reserve from queue or steal from slow peer -- `take_inflight()` - Remove from inflight (before hash check) -- `mark_piece_hash_ok()` - Mark as completed after hash verification -- `mark_piece_hash_failed()` - Requeue after hash failure -- `release_pieces_owned_by()` - Release all pieces owned by a dead peer - -### 2. `ChunkTracker` (in `chunk_tracker.rs`) - -Tracks piece/chunk download progress. Wrapped by `PieceTracker`. - -| Field | Type | Description | -|-------|------|-------------| -| `have` | `BitVec` | Pieces fully downloaded and verified | -| `queue_pieces` | `BitVec` | Pieces needed but not currently being downloaded | -| `chunk_status` | `BitVec` | Per-chunk completion status | - -### 3. `inflight_requests` (in `LivePeerState`) - -```rust -HashSet // aka InflightRequest - -struct ChunkInfo { - piece_index: ValidPieceIndex, - chunk_index: u32, - offset: u32, - size: u32, -} -``` - -Per-peer tracking of which chunks have been requested from this peer. Used for: -- Knowing which chunks to expect from peer -- Detecting unexpected data ("peer sent us a piece we did not ask") -- Cleanup when peer dies - -## Piece State Invariant - -A piece is in exactly ONE of these states: - -``` -have[piece] = true → COMPLETED (verified) -inflight.contains(piece) → IN_FLIGHT (being downloaded) -queue_pieces[piece] = true → QUEUED (needed, waiting) -none of the above → NOT_NEEDED (deprioritized) -``` - -These are **disjoint** - a piece is never in multiple states simultaneously. This invariant is maintained by `PieceTracker` methods. - -### Chunk-Piece Consistency - -If `inflight_requests` contains chunks for piece P, then: -- `inflight[P].peer` should equal this peer's address -- OR the piece was just stolen (transient state during steal) - -## State Transitions - -### Normal Download Flow - -``` -QUEUED → IN_FLIGHT → COMPLETED -``` - -1. `PieceTracker::acquire_piece()`: - - Finds piece in `queue_pieces` (or steals from slow peer) - - Calls `chunks.reserve_needed_piece(p)` → clears `queue_pieces[p] = false` - - Inserts into `inflight[p] = (peer, now)` - - Returns `AcquireResult::Reserved(p)` or `AcquireResult::Stolen { piece, from_peer }` - -2. Chunk requesting: - - For each chunk in piece, insert into `inflight_requests` - - Send Request message to peer - -3. Data arrival (`on_incoming_piece`): - - Remove chunk from `inflight_requests` - - Mark chunk complete in `chunk_status` - - If all chunks done → verify hash - -4. Piece completion: - - `PieceTracker::take_inflight(piece)` → removes from `inflight` - - Hash check passes: `PieceTracker::mark_piece_hash_ok(piece)` → sets `have[p] = true` - - Hash check fails: `PieceTracker::mark_piece_hash_failed(piece)` → sets `queue_pieces[p] = true` - -### Piece Stealing Flow - -``` -IN_FLIGHT (peer A) → IN_FLIGHT (peer B) -``` - -`PieceTracker::acquire_piece()` with steal logic: -1. Finds piece in `inflight` owned by slow peer (elapsed > threshold × avg_time) -2. Updates `inflight[p].peer = self` -3. Updates `inflight[p].started = now` -4. Returns `AcquireResult::Stolen { piece, from_peer }` - -Caller then: -5. Calls `peers.on_steal(from_peer, to_peer, piece)`: - - Sends Cancel messages to victim peer - - Removes chunks from victim's `inflight_requests` -6. Stealer requests chunks: - - Inserts into own `inflight_requests` - - Sends Request messages - -**Note:** Stealing does NOT call `reserve_needed_piece()` because the piece is already in `inflight`, not in `queue_pieces`. - -### Peer Death Flow - -``` -IN_FLIGHT → QUEUED (for pieces owned by dead peer) -``` - -1. `on_peer_died()`: - - Takes `LivePeerState` (consumes it) - - Calls `PieceTracker::release_pieces_owned_by(peer_addr)` - -2. `release_pieces_owned_by()`: - - Removes all entries from `inflight` where `peer == dead_peer_addr` - - For each removed piece, calls `chunks.mark_piece_broken_if_not_have(piece)` - - This sets `queue_pieces[p] = true` and clears `chunk_status` for piece - - Returns count of released pieces - -This maintains the invariant: pieces transition cleanly from IN_FLIGHT → QUEUED. - -### Checksum Failure Flow - -``` -IN_FLIGHT → QUEUED -``` - -1. All chunks received, hash verification fails -2. `PieceTracker::take_inflight(piece)` → removes from `inflight` -3. `PieceTracker::mark_piece_hash_failed(piece)` → calls `mark_piece_broken_if_not_have(piece)` → sets `queue_pieces[p] = true` - -### Pause Flow - -``` -IN_FLIGHT → QUEUED (for all in-flight pieces) -``` - -1. `pause()`: - - Calls `PieceTracker::into_chunks()` which: - - For each piece in `inflight`, calls `mark_piece_broken_if_not_have(piece)` - - Returns the inner `ChunkTracker` - - Stores the `ChunkTracker` for resume - -## Architectural Notes - -### State Encapsulation - -Piece state is coordinated by `PieceTracker`: -- `ChunkTracker`: `have`, `queue_pieces`, `chunk_status` (wrapped) -- `PieceTracker`: `inflight` (owned) -- `LivePeerState`: `inflight_requests` (per-peer, separate) - -`PieceTracker` methods ensure atomic state transitions that maintain invariants. Direct access to `ChunkTracker` is read-only via `chunks()`. - -### Lock Ordering - -To avoid deadlocks, locks must be acquired in a consistent order: - -1. `peers` lock (via `with_live_mut` / `with_peer_mut`) - DashMap per-peer locks -2. `TorrentStateLive` lock (via `lock_write` / `lock_read`) - global torrent state - -**Critical Rule: Never access other peers while holding a peer lock.** - -The `peers` field is a `DashMap` which uses sharded locking. When you hold -a lock on one peer's shard via `with_live_mut` or `with_peer_mut`, you must NOT: -- Call `with_peer`, `with_live_mut`, `with_peer_mut` on a different peer -- Iterate over the peers DashMap -- Call any method that internally accesses other peers (e.g., `on_steal`) - -This is because: -1. Thread A holds write lock on shard S1 (peer X) -2. Thread A tries to access peer Y which is in shard S2 -3. Thread B holds/waits for shard S2 and wants shard S1 -4. Deadlock! - -**Example of what NOT to do:** -```rust -// BAD - accessing other peers inside with_live_mut -self.peers.with_live_mut(self.addr, "example", |live| { - // ... do something ... - self.peers.on_steal(other_peer, self.addr, piece); // DEADLOCK RISK! -}); -``` - -**Correct pattern:** -```rust -// GOOD - collect info inside closure, process outside -let steal_info = self.peers.with_live_mut(self.addr, "example", |live| { - // ... return data needed for on_steal ... - Some((other_peer, piece)) -}); -if let Some((from_peer, piece)) = steal_info { - self.peers.on_steal(from_peer, self.addr, piece); // Safe - no peer lock held -} -``` - -Care must be taken when modifying state transition logic to maintain this ordering. - -### Acquire Strategy - -`PieceTracker::acquire_piece()` uses a three-phase strategy: - -1. **Try steal (10x threshold)** - Very slow peers get pieces stolen first -2. **Try reserve** - Check priority pieces, then queue_pieces -3. **Try steal (3x threshold)** - Moderately slow peers as fallback - -This balances fairness with efficiency - we prefer reserving new pieces but will steal from slow peers to avoid bottlenecks. diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/mod.rs deleted file mode 100644 index adc99c74..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/mod.rs +++ /dev/null @@ -1,756 +0,0 @@ -// The main logic of rtbit is here - connecting to peers, reading and writing messages -// to them, tracking peer state etc. -// -// ## Architecture -// There are many tasks cooperating to download the torrent. Tasks communicate both with message passing -// and shared memory. -// -// ### Shared locked state -// Shared state is access by almost all actors through RwLocks. -// -// There's one source of truth (TorrentStateLocked) for which chunks we have, need, and what peers are we waiting them from. -// -// Peer states that are important to the outsiders (tasks other than manage_peer) are in a sharded hash-map (DashMap) -// -// ### Tasks (actors) -// Peer adder task: -// - spawns new peers as they become known. It pulls them from a queue. The queue is filled in by DHT and torrent trackers. -// Also gets updated when peers are reconnecting after errors. -// -// Each peer has one main task "manage_peer". It's composed of 2 futures running as one task through tokio::select: -// - "manage_peer" - this talks to the peer over network and calls callbacks on PeerHandler. The callbacks are not async, -// and are supposed to finish quickly (apart from writing to disk, which is accounted for as "spawn_blocking"). -// - "peer_chunk_requester" - this continuously sends requests for chunks to the peer. -// it may steal chunks/pieces from other peers. -// -// ## Peer lifecycle -// State transitions: -// - queued (initial state) -> connected -// - connected -> live -// - ANY STATE -> dead (on error) -// - ANY STATE -> not_needed (when we don't need to talk to the peer anymore) -// -// When the peer dies, it's rescheduled with exponential backoff. -// -// > NOTE: deadlock notice: -// > peers and stateLocked are behind 2 different locks. -// > if you lock them in different order, this may deadlock. -// > -// > so don't lock them both at the same time at all, or at the worst lock them in the -// > same order (peers one first, then the global one). - -pub mod peer; -mod peer_handler; -pub mod peers; -pub mod stats; -mod tasks; - -use std::{ - borrow::Cow, - collections::HashSet, - net::SocketAddr, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::{Duration, Instant}, -}; - -use anyhow::Context; -use buffers::ByteBufOwned; -use librtbit_core::{ - hash_id::Id20, - lengths::{ChunkInfo, Lengths, ValidPieceIndex}, - spawn_utils::spawn_with_cancel, - speed_estimator::SpeedEstimator, - torrent_metainfo::ValidatedTorrentMetaV1Info, -}; -use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; -use peer_binary_protocol::Handshake; -use tokio::sync::{ - Notify, Semaphore, - mpsc::{Sender, unbounded_channel}, -}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, debug_span, error, info, trace, warn}; - -use crate::{ - Error, - chunk_tracker::{ChunkTracker, HaveNeededSelected}, - file_ops::FileOps, - limits::Limits, - peer_connection::WriterRequest, - piece_tracker::PieceTracker, - session::CheckedIncomingConnection, - session_stats::SessionStats, - stream_connect::ConnectionKind, - torrent_state::{peer::Peer, utils::atomic_inc}, - type_aliases::{BF, FilePriorities, FileStorage, PeerHandle}, -}; - -use self::{ - peer::{ - PeerState, - stats::snapshot::{PeerStatsFilter, PeerStatsSnapshot}, - }, - peers::PeerStates, - stats::{atomic::AtomicStats, snapshot::StatsSnapshot}, -}; - -use super::{ - ManagedTorrentShared, TorrentMetadata, - paused::TorrentStatePaused, - streaming::TorrentStreams, - utils::{TimedExistence, timeit}, -}; - -pub(crate) fn make_piece_bitfield(lengths: &Lengths) -> BF { - BF::from_boxed_slice(vec![0; lengths.piece_bitfield_bytes()].into_boxed_slice()) -} - -pub(crate) struct TorrentStateLocked { - // Coordinates piece state: what chunks we have, need, and what pieces are in-flight. - // If this is None, the torrent was paused, and this live state is useless, and needs to be dropped. - pub(crate) pieces: Option, - - // The sorted file list in which order to download them. - pub(crate) file_priorities: FilePriorities, - - // If this is None, then it was already used - fatal_errors_tx: Option>, - - unflushed_bitv_bytes: u64, -} - -impl TorrentStateLocked { - pub(crate) fn get_chunks(&self) -> crate::Result<&ChunkTracker> { - self.pieces - .as_ref() - .map(|p| p.chunks()) - .ok_or(Error::ChunkTrackerEmpty) - } - - pub(crate) fn get_pieces(&self) -> crate::Result<&PieceTracker> { - self.pieces.as_ref().ok_or(Error::ChunkTrackerEmpty) - } - - pub(crate) fn get_pieces_mut(&mut self) -> crate::Result<&mut PieceTracker> { - self.pieces.as_mut().ok_or(Error::ChunkTrackerEmpty) - } - - fn try_flush_bitv(&mut self, shared: &ManagedTorrentShared, flush_async: bool) { - if self.unflushed_bitv_bytes == 0 { - return; - } - trace!("trying to flush bitfield"); - if let Some(Err(e)) = self - .pieces - .as_mut() - .map(|pt| pt.flush_have_pieces(flush_async)) - { - warn!(id=?shared.id, info_hash = ?shared.info_hash, "error flushing bitfield: {e:#}"); - // Don't reset unflushed_bitv_bytes on error — retry on next flush attempt - } else { - trace!("flushed bitfield"); - self.unflushed_bitv_bytes = 0; - } - } -} - -const FLUSH_BITV_EVERY_BYTES: u64 = 16 * 1024 * 1024; - -/// How often the dead peer pruning task runs. -const PEER_PRUNE_INTERVAL: Duration = Duration::from_secs(60); -/// Peers in Dead/NotNeeded state are removed from the map after this duration. -const PEER_PRUNE_RETENTION: Duration = Duration::from_secs(300); - -pub enum AddIncomingPeerResult { - Added, - AlreadyActive, - ConcurrencyLimitReached, -} - -/// Minimum number of active (live + connecting) peers before triggering re-discovery. -const MIN_PEERS_THRESHOLD: u32 = 5; - -/// Minimum interval between re-discovery attempts to prevent spamming. -const REDISCOVERY_COOLDOWN: Duration = Duration::from_secs(30); - -/// How often the peer health monitor checks peer counts. -const PEER_HEALTH_CHECK_INTERVAL: Duration = Duration::from_secs(30); - -/// Long backoff duration assigned to peers whose backoff was exhausted, -/// so they are retried eventually rather than dropped permanently. -const FROZEN_PEER_RETRY_INTERVAL: Duration = Duration::from_secs(600); - -pub struct TorrentStateLive { - pub(crate) peers: PeerStates, - pub(crate) shared: Arc, - pub(crate) metadata: Arc, - _locked: RwLock, - - pub(crate) files: FileStorage, - - pub(crate) per_piece_locks: Vec>, - - pub(crate) stats: AtomicStats, - pub(crate) lengths: Lengths, - - // Limits how many active (occupying network resources) peers there are at a moment in time. - pub(crate) peer_semaphore: Arc, - - // The queue for peer manager to connect to them. - // Bounded to prevent memory exhaustion from DHT/PEX flooding. - pub(crate) peer_queue_tx: Sender, - - pub(crate) finished_notify: Notify, - pub(crate) new_pieces_notify: Notify, - - /// Notified when active peer count drops below threshold, - /// signaling that new peer discovery should be triggered. - pub(crate) rediscovery_notify: Notify, - - pub(crate) down_speed_estimator: SpeedEstimator, - pub(crate) up_speed_estimator: SpeedEstimator, - pub(crate) cancellation_token: CancellationToken, - - pub(crate) session_stats: Arc, - - pub(crate) streams: Arc, - pub(crate) have_broadcast_tx: tokio::sync::broadcast::Sender, - - pub(crate) ratelimit_upload_tx: tokio::sync::mpsc::UnboundedSender<( - tokio::sync::mpsc::UnboundedSender, - ChunkInfo, - )>, - pub(crate) ratelimits: Limits, -} - -impl TorrentStateLive { - pub(crate) fn new( - paused: TorrentStatePaused, - fatal_errors_tx: tokio::sync::oneshot::Sender, - cancellation_token: CancellationToken, - ) -> anyhow::Result> { - // Bound peer queue to prevent memory exhaustion from DHT/PEX peer flooding. - // 10000 is generous — the peer_adder task drains this quickly. - let (peer_queue_tx, peer_queue_rx) = tokio::sync::mpsc::channel(10000); - let session = paused - .shared - .session - .upgrade() - .context("session is dead, cannot start torrent")?; - let session_stats = session.stats.clone(); - let down_speed_estimator = SpeedEstimator::default(); - let up_speed_estimator = SpeedEstimator::default(); - - let have_bytes = paused.chunk_tracker.get_hns().have_bytes; - let lengths = *paused.chunk_tracker.get_lengths(); - - // TODO: make it configurable - let file_priorities = { - let mut pri = (0..paused.metadata.file_infos.len()).collect::>(); - // sort by filename, cause many torrents have random sort order. - pri.sort_unstable_by_key(|id| { - paused - .metadata - .file_infos - .get(*id) - .map(|fi| fi.relative_filename.as_path()) - }); - pri - }; - - let (have_broadcast_tx, _) = tokio::sync::broadcast::channel(128); - - let (ratelimit_upload_tx, ratelimit_upload_rx) = tokio::sync::mpsc::unbounded_channel::<( - tokio::sync::mpsc::UnboundedSender, - ChunkInfo, - )>(); - let ratelimits = Limits::new(paused.shared.options.ratelimits); - - let state = Arc::new(TorrentStateLive { - shared: paused.shared.clone(), - metadata: paused.metadata.clone(), - peers: PeerStates { - session_stats: session_stats.peers.clone(), - stats: Default::default(), - states: Default::default(), - live_outgoing_peers: Default::default(), - }, - _locked: RwLock::new(TorrentStateLocked { - pieces: Some(PieceTracker::new(paused.chunk_tracker)), - file_priorities, - fatal_errors_tx: Some(fatal_errors_tx), - unflushed_bitv_bytes: 0, - }), - files: paused.files, - stats: AtomicStats { - have_bytes: AtomicU64::new(have_bytes), - ..Default::default() - }, - lengths, - peer_semaphore: Arc::new(Semaphore::new( - paused.shared.options.peer_limit.unwrap_or(200), - )), - new_pieces_notify: Notify::new(), - peer_queue_tx, - finished_notify: Notify::new(), - rediscovery_notify: Notify::new(), - down_speed_estimator, - up_speed_estimator, - cancellation_token, - have_broadcast_tx, - session_stats, - streams: paused.streams, - per_piece_locks: (0..lengths.total_pieces()) - .map(|_| RwLock::new(())) - .collect(), - ratelimit_upload_tx, - ratelimits, - }); - - state.spawn( - debug_span!(parent: state.shared.span.clone(), "speed_estimator_updater"), - format!("[{}]speed_estimator_updater", state.shared.id), - { - let state = Arc::downgrade(&state); - async move { - loop { - let state = match state.upgrade() { - Some(state) => state, - None => return Ok(()), - }; - let now = Instant::now(); - let stats = state.stats_snapshot(); - let fetched = stats.fetched_bytes; - let remaining = state - .lock_read("get_remaining_bytes") - .get_chunks()? - .get_remaining_bytes(); - state - .down_speed_estimator - .add_snapshot(fetched, Some(remaining), now); - state - .up_speed_estimator - .add_snapshot(stats.uploaded_bytes, None, now); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - }, - ); - - state.spawn( - debug_span!(parent: state.shared.span.clone(), "peer_adder"), - format!("[{}]peer_adder", state.shared.id), - state.clone().task_peer_adder(peer_queue_rx), - ); - - state.spawn( - debug_span!(parent: state.shared.span.clone(), "upload_scheduler"), - format!("[{}]upload_scheduler", state.shared.id), - state.clone().task_upload_scheduler(ratelimit_upload_rx), - ); - - state.spawn( - debug_span!(parent: state.shared.span.clone(), "dead_peer_pruner"), - format!("[{}]dead_peer_pruner", state.shared.id), - { - let state = Arc::downgrade(&state); - async move { - let mut interval = tokio::time::interval(PEER_PRUNE_INTERVAL); - loop { - interval.tick().await; - let state = match state.upgrade() { - Some(s) => s, - None => return Ok(()), - }; - state.peers.prune_dead_peers(PEER_PRUNE_RETENTION); - } - } - }, - ); - - state.spawn( - debug_span!(parent: state.shared.span.clone(), "peer_health_monitor"), - format!("[{}]peer_health_monitor", state.shared.id), - state.clone().task_peer_health_monitor(), - ); - - if !state.shared.web_seed_urls.is_empty() { - info!(urls = ?state.shared.web_seed_urls, "torrent has webseed URLs"); - } - - Ok(state) - } - - #[track_caller] - pub(crate) fn spawn( - &self, - span: tracing::Span, - name: impl Into>, - fut: impl std::future::Future> + Send + 'static, - ) { - spawn_with_cancel(span, name, self.cancellation_token.clone(), fut); - } - - pub fn down_speed_estimator(&self) -> &SpeedEstimator { - &self.down_speed_estimator - } - - pub fn up_speed_estimator(&self) -> &SpeedEstimator { - &self.up_speed_estimator - } - - pub(crate) fn add_incoming_peer( - self: &Arc, - checked_peer: CheckedIncomingConnection, - ) -> anyhow::Result { - use dashmap::mapref::entry::Entry; - let (tx, rx) = unbounded_channel(); - let permit = match self.peer_semaphore.clone().try_acquire_owned() { - Ok(permit) => permit, - Err(_) => { - debug!("limit of live peers reached, dropping incoming peer"); - self.peers.with_peer(checked_peer.addr, |p| { - atomic_inc(&p.stats.counters.incoming_connections); - }); - return Ok(AddIncomingPeerResult::ConcurrencyLimitReached); - } - }; - - let counters = match self.peers.states.entry(checked_peer.addr) { - Entry::Occupied(mut occ) => { - let peer = occ.get_mut(); - if let Err(e) = peer.incoming_connection( - checked_peer.handshake.peer_id, - tx.clone(), - &self.peers, - checked_peer.kind, - ) { - match e { - peer::IncomingConnectionResult::AlreadyActive => { - debug!( - addr = %checked_peer.addr, - kind = %checked_peer.kind, - "peer already active, ignoring incoming connection" - ); - return Ok(AddIncomingPeerResult::AlreadyActive); - } - } - } - peer.stats.counters.clone() - } - Entry::Vacant(vac) => { - atomic_inc(&self.peers.stats.seen); - let peer = Peer::new_live_for_incoming_connection( - *vac.key(), - checked_peer.handshake.peer_id, - tx.clone(), - &self.peers, - checked_peer.kind, - ); - let counters = peer.stats.counters.clone(); - vac.insert(peer); - counters - } - }; - atomic_inc(&counters.incoming_connections); - - self.spawn( - debug_span!( - parent: self.shared.span.clone(), - "manage_incoming_peer", - addr = %checked_peer.addr - ), - format!( - "[{}][addr={}]manage_incoming_peer", - self.shared.id, checked_peer.addr - ), - aframe!( - self.clone() - .task_manage_incoming_peer(checked_peer, counters, tx, rx, permit) - ), - ); - Ok(AddIncomingPeerResult::Added) - } - - pub fn torrent(&self) -> &ManagedTorrentShared { - &self.shared - } - - pub fn info(&self) -> &ValidatedTorrentMetaV1Info { - &self.metadata.info - } - pub fn info_hash(&self) -> Id20 { - self.shared.info_hash - } - pub fn peer_id(&self) -> Id20 { - self.shared.peer_id - } - pub(crate) fn file_ops(&self) -> FileOps<'_> { - FileOps::new(&self.metadata.info, &*self.files, &self.metadata.file_infos) - } - - pub(crate) fn lock_read( - &self, - reason: &'static str, - ) -> TimedExistence> { - TimedExistence::new(timeit(reason, || self._locked.read()), reason) - } - pub(crate) fn lock_write( - &self, - reason: &'static str, - ) -> TimedExistence> { - TimedExistence::new(timeit(reason, || self._locked.write()), reason) - } - - fn set_peer_live(&self, handle: PeerHandle, h: Handshake, connection_kind: ConnectionKind) { - self.peers.with_peer_mut(handle, "set_peer_live", |p| { - p.connecting_to_live(h.peer_id, &self.peers, connection_kind); - }); - } - - pub fn get_uploaded_bytes(&self) -> u64 { - self.stats.uploaded_bytes.load(Ordering::Relaxed) - } - pub fn get_downloaded_bytes(&self) -> u64 { - self.stats - .downloaded_and_checked_bytes - .load(Ordering::Acquire) - } - - pub fn get_approx_have_bytes(&self) -> u64 { - self.stats.have_bytes.load(Ordering::Relaxed) - } - - pub fn get_hns(&self) -> Option { - self.lock_read("get_hns") - .get_chunks() - .ok() - .map(|c| *c.get_hns()) - } - - fn transmit_haves(&self, index: ValidPieceIndex) { - let _ = self.have_broadcast_tx.send(index); - } - - pub(crate) fn add_peer_if_not_seen(&self, addr: SocketAddr) -> crate::Result { - match self.peers.add_if_not_seen(addr) { - Some(handle) => handle, - None => return Ok(false), - }; - - // try_send: drop the peer if queue is full (backpressure) - let _ = self.peer_queue_tx.try_send(addr); - Ok(true) - } - - pub fn stats_snapshot(&self) -> StatsSnapshot { - use Ordering::*; - let downloaded_bytes = self.stats.downloaded_and_checked_bytes.load(Relaxed); - StatsSnapshot { - downloaded_and_checked_bytes: downloaded_bytes, - downloaded_and_checked_pieces: self.stats.downloaded_and_checked_pieces.load(Relaxed), - fetched_bytes: self.stats.fetched_bytes.load(Relaxed), - uploaded_bytes: self.stats.uploaded_bytes.load(Relaxed), - total_piece_download_ms: self.stats.total_piece_download_ms.load(Relaxed), - peer_stats: self.peers.stats(), - } - } - - pub fn per_peer_stats_snapshot(&self, filter: PeerStatsFilter) -> PeerStatsSnapshot { - PeerStatsSnapshot { - peers: self - .peers - .states - .iter() - .filter(|e| filter.state.matches(e.value().get_state())) - .map(|e| (e.key().to_string(), e.value().into())) - .collect(), - } - } - - pub async fn wait_until_completed(&self) { - if self.is_finished() { - return; - } - self.finished_notify.notified().await; - } - - pub fn pause(&self) -> anyhow::Result { - self.cancellation_token.cancel(); - - let mut g = self.lock_write("pause"); - - // It should be impossible to make a fatal error after pausing. - g.fatal_errors_tx.take(); - - let piece_tracker = g - .pieces - .take() - .context("bug: pausing already paused torrent")?; - // into_chunks() will requeue any in-flight pieces - let chunk_tracker = piece_tracker.into_chunks(); - - Ok(TorrentStatePaused { - shared: self.shared.clone(), - metadata: self.metadata.clone(), - files: self.files.take()?, - chunk_tracker, - streams: self.streams.clone(), - }) - } - - pub(crate) fn on_fatal_error(&self, e: anyhow::Error) -> anyhow::Result<()> { - let mut g = self.lock_write("fatal_error"); - let tx = g - .fatal_errors_tx - .take() - .context("fatal_errors_tx already taken")?; - let res = anyhow::anyhow!("fatal error: {:?}", e); - if tx.send(e).is_err() { - error!(id=self.shared.id, info_hash=?self.shared.info_hash, "fatal error receiver is dead, cancelling torrent"); - self.cancellation_token.cancel(); - } - Err(res) - } - - pub(crate) fn update_only_files(&self, only_files: &HashSet) -> anyhow::Result<()> { - let mut g = self.lock_write("update_only_files"); - let pt = g.get_pieces_mut()?; - let hns = pt.update_only_files(&self.metadata.file_infos, only_files)?; - if !hns.finished() { - self.reconnect_all_not_needed_peers(); - } - Ok(()) - } - - // If we have all selected pieces but not necessarily all pieces. - pub(crate) fn is_finished(&self) -> bool { - self.get_hns().map(|h| h.finished()).unwrap_or_default() - } - - fn has_active_streams_unfinished_files(&self, state: &TorrentStateLocked) -> bool { - let chunks = match state.get_chunks() { - Ok(c) => c, - Err(_) => return false, - }; - self.streams - .streamed_file_ids() - .any(|file_id| !chunks.is_file_finished(&self.metadata.file_infos[file_id])) - } - - // We might have the torrent "finished" i.e. no selected files. But if someone is streaming files despite - // them being selected, we aren't fully "finished". - pub(crate) fn is_finished_and_no_active_streams(&self) -> bool { - self.is_finished() - && !self.has_active_streams_unfinished_files( - &self.lock_read("is_finished_and_dont_need_peers"), - ) - } - - pub(crate) fn on_piece_completed(&self, id: ValidPieceIndex) -> anyhow::Result<()> { - if let Err(e) = self.files.on_piece_completed(id) { - debug!(?id, "file storage errored in on_piece_completed(): {e:#}"); - } - let mut g = self.lock_write("on_piece_completed"); - let locked = &mut **g; - let pieces = locked.get_pieces_mut()?; - - // if we have all the pieces of the file, reopen it read only - for (idx, file_info) in self - .metadata - .file_infos - .iter() - .enumerate() - .skip_while(|(_, fi)| !fi.piece_range.contains(&id.get())) - .take_while(|(_, fi)| fi.piece_range.contains(&id.get())) - { - let _remaining = pieces.update_file_have_on_piece_completed(id, idx, file_info); - } - - self.streams - .wake_streams_on_piece_completed(id, self.metadata.lengths()); - - locked.unflushed_bitv_bytes += self.metadata.lengths().piece_length(id) as u64; - if locked.unflushed_bitv_bytes >= FLUSH_BITV_EVERY_BYTES { - locked.try_flush_bitv(&self.shared, true) - } - - let chunks = locked.get_chunks()?; - if chunks.is_finished() { - if chunks.get_selected_pieces()[id.get_usize()] { - locked.try_flush_bitv(&self.shared, false); - info!(id=self.shared.id, info_hash=?self.shared.info_hash, "torrent finished downloading"); - } - self.finished_notify.notify_waiters(); - - if !self.has_active_streams_unfinished_files(locked) { - // prevent deadlocks. - drop(g); - // There is not point being connected to peers that have all the torrent, when - // we don't need anything from them, and they don't need anything from us. - self.disconnect_all_peers_that_have_full_torrent(); - } - } - Ok(()) - } - - fn disconnect_all_peers_that_have_full_torrent(&self) { - for mut pe in self.peers.states.iter_mut() { - if let PeerState::Live(l) = pe.value().get_state() - && l.has_full_torrent(self.lengths.total_pieces() as usize) - { - let prev = pe.value_mut().set_not_needed(&self.peers); - let _ = prev - .take_live_no_counters() - .unwrap() - .tx - .send(WriterRequest::Disconnect(Ok(()))); - } - } - } - - pub(crate) fn reconnect_all_not_needed_peers(&self) { - self.peers - .states - .iter_mut() - .filter_map(|mut p| p.value_mut().reconnect_not_needed_peer(&self.peers)) - .map(|socket_addr| self.peer_queue_tx.try_send(socket_addr)) - .take_while(|r| r.is_ok()) - .last(); - } - - /// Returns the number of active peers (live + connecting). - pub(crate) fn get_active_peer_count(&self) -> u32 { - let stats = self.peers.stats(); - stats.live + stats.connecting - } - - /// Reset backoff timers for all dead peers and re-queue them. - /// This is called during re-discovery to give dead peers another chance. - pub(crate) fn requeue_dead_peers(&self) { - let mut requeued = 0u32; - for mut entry in self.peers.states.iter_mut() { - let peer = entry.value_mut(); - if matches!(peer.get_state(), PeerState::Dead) { - peer.stats.reset_backoff(); - peer.set_state(PeerState::Queued, &self.peers); - let addr = peer.addr; - // Don't hold the lock while sending - drop(entry); - if self.peer_queue_tx.try_send(addr).is_err() { - break; - } - requeued += 1; - } - } - if requeued > 0 { - debug!( - id = self.shared.id, - requeued, "re-queued dead peers during rediscovery" - ); - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/mod.rs deleted file mode 100644 index 0abe4723..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/mod.rs +++ /dev/null @@ -1,293 +0,0 @@ -pub mod stats; - -use std::collections::HashSet; -use std::net::SocketAddr; -use std::sync::atomic::Ordering; -use std::time::Instant; - -use librtbit_core::hash_id::Id20; -use librtbit_core::lengths::ChunkInfo; - -use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; -use tracing::debug; - -use crate::peer_connection::WriterRequest; -use crate::stream_connect::ConnectionKind; -use crate::type_aliases::BF; - -use super::PeerStates; - -pub(crate) type InflightRequest = ChunkInfo; -pub(crate) type PeerRx = UnboundedReceiver; -pub(crate) type PeerTx = UnboundedSender; - -#[derive(Debug)] -pub(crate) struct Peer { - pub addr: SocketAddr, - state: PeerState, - pub stats: stats::atomic::PeerStats, - pub outgoing_address: Option, - /// Tracks when the peer last transitioned to a terminal state (Dead/NotNeeded). - /// Used by the pruning task to remove stale entries from the peer map. - pub last_state_change: Instant, -} - -impl Peer { - pub fn new_live_for_incoming_connection( - addr: SocketAddr, - peer_id: Id20, - tx: PeerTx, - counters: &PeerStates, - connection_kind: ConnectionKind, - ) -> Self { - let state = PeerState::Live(LivePeerState::new(peer_id, tx, true, connection_kind)); - for counter in [&counters.session_stats, &counters.stats] { - counter.inc(&state); - } - Self { - addr, - state, - stats: Default::default(), - outgoing_address: None, - last_state_change: Instant::now(), - } - } - - pub fn new_with_outgoing_address(addr: SocketAddr) -> Self { - Self { - addr, - outgoing_address: Some(addr), - stats: Default::default(), - state: Default::default(), - last_state_change: Instant::now(), - } - } - - pub(crate) fn reconnect_not_needed_peer( - &mut self, - counters: &PeerStates, - ) -> Option { - if let PeerState::NotNeeded = self.get_state() { - match self.outgoing_address { - None => None, - Some(socket_addr) if self.addr == socket_addr => { - self.set_state(PeerState::Queued, counters); - Some(socket_addr) - } - Some(socket_addr) => { - debug!( - peer = %self.addr, - outgoing_addr = %socket_addr, - "peer will by retried on different address", - ); - Some(socket_addr) - } - } - } else { - None - } - } -} - -#[derive(Debug, Default)] -pub(crate) enum PeerState { - #[default] - // Will be tried to be connected as soon as possible. - Queued, - Connecting(PeerTx), - Live(LivePeerState), - // There was an error, and it's waiting for exponential backoff. - Dead, - // We don't need to do anything with the peer any longer. - // The peer has the full torrent, and we have the full torrent, so no need - // to keep talking to it. - NotNeeded, -} - -impl std::fmt::Display for PeerState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name()) - } -} - -impl PeerState { - pub fn name(&self) -> &'static str { - match self { - PeerState::Queued => "queued", - PeerState::Connecting(_) => "connecting", - PeerState::Live(_) => "live", - PeerState::Dead => "dead", - PeerState::NotNeeded => "not needed", - } - } - - pub fn take_live_no_counters(self) -> Option { - match self { - PeerState::Live(l) => Some(l), - _ => None, - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum IncomingConnectionResult { - #[error("peer already active")] - AlreadyActive, -} - -impl Peer { - pub fn get_state(&self) -> &PeerState { - &self.state - } - - pub fn take_state(&mut self, counters: &PeerStates) -> PeerState { - self.set_state(Default::default(), counters) - } - - pub fn destroy(self, counters: &PeerStates) { - for counter in [&counters.session_stats, &counters.stats] { - counter.dec(&self.state); - } - if let (Some(addr), PeerState::Live(..)) = (self.outgoing_address, &self.state) { - counters.live_outgoing_peers.write().remove(&addr); - } - } - - pub fn set_state(&mut self, new: PeerState, counters: &PeerStates) -> PeerState { - for counter in [&counters.session_stats, &counters.stats] { - counter.incdec(&self.state, &new); - } - if let Some(addr) = self.outgoing_address { - if matches!(&self.state, PeerState::Live(..)) { - counters.live_outgoing_peers.write().remove(&addr); - } - if matches!(&new, PeerState::Live(..)) - && self - .stats - .counters - .outgoing_connections - .load(Ordering::Relaxed) - > 0 - { - counters.live_outgoing_peers.write().insert(addr); - } - } - - self.last_state_change = Instant::now(); - std::mem::replace(&mut self.state, new) - } - - pub fn get_live(&self) -> Option<&LivePeerState> { - match &self.state { - PeerState::Live(l) => Some(l), - _ => None, - } - } - - pub fn get_live_mut(&mut self) -> Option<&mut LivePeerState> { - match &mut self.state { - PeerState::Live(l) => Some(l), - _ => None, - } - } - - pub fn idle_to_connecting(&mut self, counters: &PeerStates) -> Option<(PeerRx, PeerTx)> { - match &self.state { - PeerState::Queued | PeerState::NotNeeded => { - let (tx, rx) = unbounded_channel(); - let tx_2 = tx.clone(); - self.set_state(PeerState::Connecting(tx), counters); - Some((rx, tx_2)) - } - _ => None, - } - } - - pub fn incoming_connection( - &mut self, - peer_id: Id20, - tx: PeerTx, - counters: &PeerStates, - connection_kind: ConnectionKind, - ) -> Result<(), IncomingConnectionResult> { - if matches!(&self.state, PeerState::Connecting(..) | PeerState::Live(..)) { - return Err(IncomingConnectionResult::AlreadyActive); - } - match self.take_state(counters) { - PeerState::Queued | PeerState::Dead | PeerState::NotNeeded => { - self.set_state( - PeerState::Live(LivePeerState::new(peer_id, tx, true, connection_kind)), - counters, - ); - } - PeerState::Connecting(..) | PeerState::Live(..) => unreachable!(), - } - Ok(()) - } - - pub fn connecting_to_live( - &mut self, - peer_id: Id20, - counters: &PeerStates, - conn_kind: ConnectionKind, - ) -> Option<&mut LivePeerState> { - if let PeerState::Connecting(_) = &self.state { - let tx = match self.take_state(counters) { - PeerState::Connecting(tx) => tx, - _ => unreachable!(), - }; - self.set_state( - PeerState::Live(LivePeerState::new(peer_id, tx, false, conn_kind)), - counters, - ); - self.get_live_mut() - } else { - None - } - } - - pub fn set_not_needed(&mut self, counters: &PeerStates) -> PeerState { - self.set_state(PeerState::NotNeeded, counters) - } -} - -#[derive(Debug)] -pub(crate) struct LivePeerState { - #[allow(dead_code)] - peer_id: Id20, - - pub peer_interested: bool, - - // This is used to track the pieces the peer has. - pub bitfield: BF, - - // When the peer sends us data this is used to track if we asked for it. - pub inflight_requests: HashSet, - - // The main channel to send requests to peer. - pub tx: PeerTx, - - pub connection_kind: ConnectionKind, -} - -impl LivePeerState { - pub fn new( - peer_id: Id20, - tx: PeerTx, - initial_interested: bool, - connection_kind: ConnectionKind, - ) -> Self { - LivePeerState { - peer_id, - peer_interested: initial_interested, - bitfield: BF::default(), - inflight_requests: Default::default(), - tx, - connection_kind, - } - } - - pub fn has_full_torrent(&self, total_pieces: usize) -> bool { - self.bitfield.get(0..total_pieces).is_some_and(|s| s.all()) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/atomic.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/atomic.rs deleted file mode 100644 index 17b0b077..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/atomic.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::{ - sync::{ - Arc, - atomic::{AtomicU32, AtomicU64, Ordering}, - }, - time::Duration, -}; - -use backon::{BackoffBuilder, ExponentialBackoff, ExponentialBuilder}; - -#[derive(Default, Debug)] -pub(crate) struct PeerCountersAtomic { - pub fetched_bytes: AtomicU64, - pub uploaded_bytes: AtomicU64, - pub total_time_connecting_ms: AtomicU64, - pub incoming_connections: AtomicU32, - pub outgoing_connection_attempts: AtomicU32, - pub outgoing_connections: AtomicU32, - pub errors: AtomicU32, - pub fetched_chunks: AtomicU32, - pub downloaded_and_checked_pieces: AtomicU32, - pub downloaded_and_checked_bytes: AtomicU64, - pub total_piece_download_ms: AtomicU64, - pub times_stolen_from_me: AtomicU32, - pub times_i_stole: AtomicU32, -} - -impl PeerCountersAtomic { - pub(crate) fn on_piece_completed(&self, piece_len: u64, elapsed: Duration) { - #[allow(clippy::cast_possible_truncation)] - let elapsed = elapsed.as_millis() as u64; - self.total_piece_download_ms - .fetch_add(elapsed, Ordering::Release); - self.downloaded_and_checked_pieces - .fetch_add(1, Ordering::Release); - self.downloaded_and_checked_bytes - .fetch_add(piece_len, Ordering::Relaxed); - } - - pub(crate) fn average_piece_download_time(&self) -> Option { - let downloaded_pieces = self.downloaded_and_checked_pieces.load(Ordering::Acquire); - let total_download_time = self.total_piece_download_ms.load(Ordering::Acquire); - if total_download_time == 0 || downloaded_pieces == 0 { - return None; - } - Some(Duration::from_millis( - total_download_time / downloaded_pieces as u64, - )) - } -} - -fn backoff() -> ExponentialBackoff { - ExponentialBuilder::new() - .with_min_delay(Duration::from_secs(10)) - .with_factor(6.) - .with_jitter() - .with_max_delay(Duration::from_secs(3600)) - .with_total_delay(Some(Duration::from_secs(86400))) - .without_max_times() - .build() -} - -#[derive(Debug)] -pub(crate) struct PeerStats { - pub counters: Arc, - pub backoff: ExponentialBackoff, -} - -impl Default for PeerStats { - fn default() -> Self { - Self { - counters: Arc::new(Default::default()), - backoff: backoff(), - } - } -} - -impl PeerStats { - pub fn reset_backoff(&mut self) { - self.backoff = backoff(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Verify that the backoff produces delays that grow over time - /// and that the cumulative delay is bounded by total_delay (86400s). - #[test] - fn test_backoff_produces_bounded_delays() { - let mut stats = PeerStats::default(); - - let mut total_delay = Duration::ZERO; - let mut count = 0; - let max_iterations = 1000; - - for dur in stats.backoff.by_ref() { - total_delay += dur; - count += 1; - if count >= max_iterations { - break; - } - } - - // Backoff should have produced at least some delays. - assert!(count > 0, "backoff should produce at least one delay"); - - // The total delay should be bounded by the configured total_delay (86400s) - // plus some margin for the last jittered value. - assert!( - total_delay <= Duration::from_secs(86400 + 7200), - "cumulative delay should be bounded near total_delay, got {:?}", - total_delay - ); - } - - /// Verify that after reset, the backoff produces delays again - /// starting from the minimum delay. - #[test] - fn test_backoff_reset_produces_new_delays() { - let mut stats = PeerStats::default(); - - // Consume some delays to advance the backoff state. - for _ in 0..5 { - stats.backoff.next(); - } - - // Reset the backoff. - stats.reset_backoff(); - - // Should produce delays again starting from min_delay. - let next = stats.backoff.next(); - assert!(next.is_some(), "backoff should produce delays after reset"); - let dur = next.unwrap(); - // min_delay is 10s, with jitter it can be up to 20s. - assert!( - dur >= Duration::from_secs(10) && dur <= Duration::from_secs(20), - "first delay after reset should be near min_delay (10s +/- jitter), got {:?}", - dur - ); - } - - /// Verify that reset_backoff() creates a fresh backoff - /// independent of the previous state. - #[test] - fn test_backoff_reset_is_independent() { - let mut stats = PeerStats::default(); - - // Advance the backoff significantly. - let mut advanced_count = 0; - for _ in stats.backoff.by_ref() { - advanced_count += 1; - if advanced_count >= 20 { - break; - } - } - - // Reset. - stats.reset_backoff(); - - // After reset, we should get the same number of delays - // as a fresh backoff (approximately). - let mut reset_count = 0; - for _ in stats.backoff.by_ref() { - reset_count += 1; - if reset_count >= 100 { - break; - } - } - - assert!(reset_count > 0, "reset backoff should produce delays"); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/mod.rs deleted file mode 100644 index 1f2b6575..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod atomic; -pub mod snapshot; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/snapshot.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/snapshot.rs deleted file mode 100644 index d2b3d956..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer/stats/snapshot.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::{collections::HashMap, sync::atomic::Ordering}; - -use serde::{Deserialize, Serialize}; - -use crate::{ - stream_connect::ConnectionKind, - torrent_state::live::peer::{Peer, PeerState}, -}; - -#[derive(Serialize, Deserialize)] -pub struct PeerCounters { - pub incoming_connections: u32, - pub fetched_bytes: u64, - pub uploaded_bytes: u64, - pub total_time_connecting_ms: u64, - pub connection_attempts: u32, - pub connections: u32, - pub errors: u32, - pub fetched_chunks: u32, - pub downloaded_and_checked_pieces: u32, - pub total_piece_download_ms: u64, - pub times_stolen_from_me: u32, - pub times_i_stole: u32, -} - -#[derive(Serialize)] -pub struct PeerStats { - pub counters: PeerCounters, - pub state: &'static str, - pub conn_kind: Option, -} - -impl From<&super::atomic::PeerCountersAtomic> for PeerCounters { - fn from(counters: &super::atomic::PeerCountersAtomic) -> Self { - Self { - incoming_connections: counters.incoming_connections.load(Ordering::Relaxed), - fetched_bytes: counters.fetched_bytes.load(Ordering::Relaxed), - uploaded_bytes: counters.uploaded_bytes.load(Ordering::Relaxed), - total_time_connecting_ms: counters.total_time_connecting_ms.load(Ordering::Relaxed), - connection_attempts: counters - .outgoing_connection_attempts - .load(Ordering::Relaxed), - connections: counters.outgoing_connections.load(Ordering::Relaxed), - errors: counters.errors.load(Ordering::Relaxed), - fetched_chunks: counters.fetched_chunks.load(Ordering::Relaxed), - downloaded_and_checked_pieces: counters - .downloaded_and_checked_pieces - .load(Ordering::Relaxed), - total_piece_download_ms: counters.total_piece_download_ms.load(Ordering::Relaxed), - times_i_stole: counters.times_i_stole.load(Ordering::Relaxed), - times_stolen_from_me: counters.times_stolen_from_me.load(Ordering::Relaxed), - } - } -} - -impl From<&Peer> for PeerStats { - fn from(peer: &Peer) -> Self { - let state = peer.get_state(); - Self { - counters: peer.stats.counters.as_ref().into(), - state: state.name(), - conn_kind: match state { - PeerState::Live(l) => Some(l.connection_kind), - _ => None, - }, - } - } -} - -#[derive(Serialize)] -pub struct PeerStatsSnapshot { - pub peers: HashMap, -} - -#[derive(Clone, Copy, Default, Deserialize)] -pub enum PeerStatsFilterState { - #[serde(rename = "all")] - All, - #[default] - #[serde(rename = "live")] - Live, -} - -impl PeerStatsFilterState { - pub(crate) fn matches(&self, s: &PeerState) -> bool { - matches!((self, s), (Self::All, _) | (Self::Live, PeerState::Live(_))) - } -} - -#[derive(Default, Deserialize)] -pub struct PeerStatsFilter { - #[serde(default)] - pub state: PeerStatsFilterState, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer_handler.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer_handler.rs deleted file mode 100644 index 423f3f83..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peer_handler.rs +++ /dev/null @@ -1,1160 +0,0 @@ -use std::{ - net::SocketAddr, - num::NonZeroU32, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use anyhow::{Context, bail}; -use buffers::{ByteBuf, ByteBufOwned}; -use clone_to_owned::CloneToOwned; -use librtbit_core::{constants::CHUNK_SIZE, lengths::ChunkInfo, spawn_utils::spawn_with_cancel}; -use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; -use peer_binary_protocol::{ - Handshake, Message, Piece, Request, - extended::{ - ExtendedMessage, - handshake::ExtendedHandshake, - ut_holepunch::{HolepunchErrorCode, HolepunchMessage, HolepunchMsgType}, - ut_metadata::{UtMetadata, UtMetadataData}, - ut_pex::UtPex, - }, -}; -use tokio::sync::{Notify, Semaphore}; -use tokio_util::sync::CancellationToken; -use tracing::{debug, debug_span, error, trace, warn}; - -use crate::{ - Error, - chunk_tracker::ChunkMarkingResult, - peer_connection::{PeerConnectionHandler, WriterRequest}, - piece_tracker::{AcquireRequest, AcquireResult}, - stream_connect::ConnectionKind, - torrent_state::utils::{TimedExistence, timeit}, - type_aliases::{BF, PeerHandle}, -}; - -use super::{ - TorrentStateLive, TorrentStateLocked, make_piece_bitfield, - peer::{PeerState, PeerTx, stats::atomic::PeerCountersAtomic as AtomicPeerCounters}, -}; - -pub(crate) struct PeerHandlerLocked { - pub i_am_choked: bool, -} - -// All peer state that would never be used by other actors should pe put here. -// This state tracks a live peer. -pub(crate) struct PeerHandler { - pub(crate) state: Arc, - pub(crate) counters: Arc, - // Semantically, we don't need an RwLock here, as this is only requested from - // one future (requester + manage_peer). - // - // However as PeerConnectionHandler takes &self everywhere, we need shared mutability. - // RefCell would do, but tokio is unhappy when we use it. - pub(crate) _locked: RwLock, - - // This is used to unpause chunk requester once the bitfield - // is received. - pub(crate) on_bitfield_notify: Notify, - - // This is used to unpause after we were choked. - pub(crate) unchoke_notify: Notify, - - // This is used to limit the number of chunk requests we send to a peer at a time. - pub(crate) requests_sem: Semaphore, - - pub(crate) addr: SocketAddr, - pub(crate) incoming: bool, - pub(crate) tx: PeerTx, - - pub(crate) first_message_received: AtomicBool, - - pub(crate) cancel_token: CancellationToken, -} - -impl PeerConnectionHandler for &'_ PeerHandler { - fn on_connected(&self, connection_time: Duration) { - self.counters - .outgoing_connections - .fetch_add(1, Ordering::Relaxed); - #[allow(clippy::cast_possible_truncation)] - self.counters - .total_time_connecting_ms - .fetch_add(connection_time.as_millis() as u64, Ordering::Relaxed); - } - - async fn on_received_message(&self, message: Message<'_>) -> anyhow::Result<()> { - // The first message must be "bitfield", but if it's not sent, - // assume the bitfield is all zeroes and was sent. - if !matches!(&message, Message::Bitfield(..)) - && !self.first_message_received.swap(true, Ordering::Relaxed) - { - self.on_bitfield_notify.notify_waiters(); - } - - match message { - Message::Request(request) => { - self.on_download_request(request) - .context("on_download_request")?; - } - Message::Bitfield(b) => self - .on_bitfield(b.clone_to_owned(None)) - .context("on_bitfield")?, - Message::Choke => self.on_i_am_choked(), - Message::Unchoke => self.on_i_am_unchoked(), - Message::Interested => self.on_peer_interested(), - Message::Piece(piece) => self - .on_received_piece(piece) - .await - .context("on_received_piece")?, - Message::KeepAlive => { - trace!("keepalive received"); - } - Message::Have(h) => self.on_have(h), - Message::NotInterested => { - trace!("received \"not interested\", but we don't process it yet") - } - Message::Cancel(_) => { - trace!("received \"cancel\", but we don't process it yet") - } - Message::Extended(ExtendedMessage::UtMetadata(UtMetadata::Request( - metadata_piece_id, - ))) => { - if self.state.metadata.info.info().private { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - "received noncompliant ut_metadata message from {}, ignoring", - self.addr - ); - } else { - self.send_metadata_piece(metadata_piece_id) - .with_context(|| { - format!("error sending metadata piece {metadata_piece_id}") - })?; - } - } - Message::Extended(ExtendedMessage::UtPex(pex)) => { - if self.state.metadata.info.info().private { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - "received noncompliant PEX message from {}, ignoring", - self.addr - ); - } else { - self.on_pex_message(pex); - } - } - Message::Extended(ExtendedMessage::UtHolepunch(msg)) => { - self.on_holepunch_message(msg); - } - message => { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - "received unsupported message {:?}, ignoring", message - ); - } - }; - Ok(()) - } - - fn serialize_bitfield_message_to_buf(&self, buf: &mut [u8]) -> anyhow::Result { - let g = self.state.lock_read("serialize_bitfield_message_to_buf"); - let msg = Message::Bitfield(ByteBuf(g.get_chunks()?.get_have_pieces().as_bytes())); - let len = msg.serialize(buf, &Default::default)?; - trace!("sending: {:?}, length={}", &msg, len); - Ok(len) - } - - fn on_handshake(&self, handshake: Handshake, ckind: ConnectionKind) -> anyhow::Result<()> { - self.state.set_peer_live(self.addr, handshake, ckind); - Ok(()) - } - - fn on_uploaded_bytes(&self, bytes: u32) { - self.counters - .uploaded_bytes - .fetch_add(bytes as u64, Ordering::Relaxed); - self.state - .stats - .uploaded_bytes - .fetch_add(bytes as u64, Ordering::Relaxed); - self.state - .session_stats - .counters - .uploaded_bytes - .fetch_add(bytes as u64, Ordering::Relaxed); - } - - fn read_chunk(&self, chunk: &ChunkInfo, buf: &mut [u8]) -> anyhow::Result<()> { - self.state.file_ops().read_chunk(self.addr, chunk, buf) - } - - fn on_extended_handshake(&self, hs: &ExtendedHandshake) -> anyhow::Result<()> { - if !self.state.metadata.info.info().private && hs.m.ut_pex.is_some() { - spawn_with_cancel( - debug_span!( - parent: self.state.shared.span.clone(), - "sending_pex_to_peer", - peer = ?self.addr, - ), - format!( - "[{}][addr={}]sending_pex_to_peer", - self.state.shared.id, self.addr - ), - self.cancel_token.clone(), - self.state - .clone() - .task_send_pex_to_peer(self.addr, self.tx.clone()), - ); - } - // Lets update outgoing Socket address for incoming connection - if self.incoming - && let Some(port) = hs.port() - { - let peer_ip = hs.ip_addr().unwrap_or(self.addr.ip()); - let outgoing_addr = SocketAddr::new(peer_ip, port); - self.state - .peers - .with_peer_mut(self.addr, "update outgoing addr", |peer| { - peer.outgoing_address = Some(outgoing_addr) - }); - } - - Ok(()) - } - - fn should_send_bitfield(&self) -> bool { - if self.state.torrent().options.disable_upload() { - return false; - } - - self.state.get_approx_have_bytes() > 0 - } - - fn should_transmit_have(&self, id: librtbit_core::lengths::ValidPieceIndex) -> bool { - if self.state.shared.options.disable_upload() { - return false; - } - let have = self - .state - .peers - .with_live(self.addr, |l| { - l.bitfield.get(id.get_usize()).map(|p| *p).unwrap_or(true) - }) - .unwrap_or(true); - !have - } - - fn update_my_extended_handshake( - &self, - handshake: &mut ExtendedHandshake, - ) -> anyhow::Result<()> { - let info_bytes = &self.state.metadata.info_bytes; - if !info_bytes.is_empty() - && let Ok(len) = info_bytes.len().try_into() - { - handshake.metadata_size = Some(len); - } - - Ok(()) - } -} - -impl PeerHandler { - pub(crate) fn on_peer_died(self, error: Option) -> crate::Result<()> { - let peers = &self.state.peers; - let handle = self.addr; - let mut pe = match peers.states.get_mut(&handle) { - Some(peer) => TimedExistence::new(peer, "on_peer_died"), - None => { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - addr=?handle, - "bug: peer not found in table. Forgetting it forever" - ); - return Ok(()); - } - }; - let prev = pe.value_mut().take_state(peers); - - match prev { - PeerState::Connecting(_) => {} - PeerState::Live(live) => { - let mut g = self.state.lock_write("mark_chunk_requests_canceled"); - - // Release all pieces owned by this peer (fixes the bug where pieces - // could be in both queue_pieces AND inflight_pieces after peer death) - let released = g.get_pieces_mut()?.release_pieces_owned_by(self.addr); - if released > 0 { - trace!( - "peer dead, released {} in-flight pieces back to queue", - released - ); - } - - // Also handle any chunk-level inflight requests - let had_inflight = !live.inflight_requests.is_empty(); - for req in live.inflight_requests { - trace!( - "peer dead, marking chunk request cancelled, index={}, chunk={}", - req.piece_index.get(), - req.chunk_index - ); - } - - if released > 0 || had_inflight { - self.state.new_pieces_notify.notify_waiters(); - } - } - PeerState::NotNeeded => { - // Restore it as std::mem::take() replaced it above. - pe.value_mut().set_state(PeerState::NotNeeded, peers); - return Ok(()); - } - s @ PeerState::Queued | s @ PeerState::Dead => { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - addr = ?handle, - "bug: peer was in a wrong state {s:?}, ignoring it forever" - ); - // Prevent deadlocks. - drop(pe); - self.state.peers.drop_peer(handle); - return Ok(()); - } - }; - - let _error = match error { - Some(e) => e, - None => { - trace!("peer died without errors, not re-queueing"); - pe.value_mut().set_state(PeerState::NotNeeded, peers); - return Ok(()); - } - }; - - self.counters.errors.fetch_add(1, Ordering::Relaxed); - - if self.state.is_finished_and_no_active_streams() { - debug!("torrent finished, not re-queueing"); - pe.value_mut().set_state(PeerState::NotNeeded, peers); - return Ok(()); - } - - pe.value_mut().set_state(PeerState::Dead, peers); - - if self.incoming { - // do not retry incoming peers - debug!( - peer = handle.to_string(), - "incoming peer died, not re-queueing" - ); - return Ok(()); - } - - let backoff = pe.value_mut().stats.backoff.next(); - - // Prevent deadlocks. - drop(pe); - - // When backoff is exhausted, instead of permanently dropping the peer, - // reset its backoff and schedule a long retry. This ensures peers can be - // retried when re-discovery triggers (e.g., after a network disruption). - let dur = match backoff { - Some(dur) => dur, - None => { - debug!( - "backoff exhausted, resetting with long retry interval ({}s)", - super::FROZEN_PEER_RETRY_INTERVAL.as_secs() - ); - let reset_ok = peers - .with_peer_mut(handle, "reset_backoff", |peer| { - peer.stats.reset_backoff(); - }) - .is_some(); - if !reset_ok { - return Ok(()); - } - super::FROZEN_PEER_RETRY_INTERVAL - } - }; - - if cfg!(feature = "_disable_reconnect_test") { - return Ok(()); - } - self.state.clone().spawn( - debug_span!( - parent: self.state.shared.span.clone(), - "wait_for_peer", - peer = ?handle, - duration = format!("{dur:?}") - ), - format!("[{}][addr={}]wait_for_peer", self.state.shared.id, handle), - async move { - trace!("waiting to reconnect again"); - tokio::time::sleep(dur).await; - trace!("finished waiting"); - let should_requeue = self - .state - .peers - .with_peer_mut(handle, "dead_to_queued", |peer| { - match peer.get_state() { - PeerState::Dead => { - peer.set_state(PeerState::Queued, &self.state.peers); - true - } - // Peer reconnected (e.g. via incoming connection) while we were - // waiting. No need to queue - it's already connected or queued. - PeerState::Live(_) | PeerState::Connecting(_) | PeerState::Queued => { - trace!( - state = peer.get_state().name(), - "peer is no longer dead, skipping requeue" - ); - false - } - // Don't need this peer anymore. - PeerState::NotNeeded => false, - } - }) - .unwrap_or(false); - if should_requeue { - // try_send: drop the peer if queue is full - let _ = self.state.peer_queue_tx.try_send(handle); - } - Ok::<_, Error>(()) - }, - ); - Ok(()) - } - - /// Acquire a piece for this peer: try steal (10x) -> reserve -> steal (3x). - /// - /// Returns the piece index to download, or None if no pieces are available. - fn acquire_next_piece(&self) -> crate::Result> { - // Steal info to process after releasing the peer lock - let mut steal_info: Option<(SocketAddr, librtbit_core::lengths::ValidPieceIndex)> = None; - - let result = self - .state - .peers - .with_live_mut(self.addr, "acquire_next_piece", |live| { - if self.lock_read("i am choked").i_am_choked { - debug!("we are choked, can't acquire piece"); - return Ok(None); - } - let mut g = self.state.lock_write("acquire_next_piece"); - - let bf = &live.bitfield; - // Extract references to disjoint fields - let TorrentStateLocked { - pieces, - file_priorities, - .. - } = &mut **g; - let pieces = pieces.as_mut().ok_or(Error::ChunkTrackerEmpty)?; - let result = pieces.acquire_piece(AcquireRequest { - peer: self.addr, - peer_avg_time: self.counters.average_piece_download_time(), - priority_pieces: self.state.streams.iter_next_pieces(&self.state.lengths), - file_priorities, - file_infos: &self.state.metadata.file_infos, - peer_has_piece: |p| bf.get(p.get() as usize).map(|v| *v) == Some(true), - can_steal: |p| { - self.state.per_piece_locks[p.get_usize()] - .try_write() - .is_some() - }, - }); - - match result { - AcquireResult::Reserved(piece) => { - trace!("reserved piece {}", piece); - Ok(Some(piece)) - } - AcquireResult::Stolen { piece, from_peer } => { - debug!("stole piece {} from {}", piece, from_peer); - // Store steal info to process after releasing peer lock to avoid deadlock - steal_info = Some((from_peer, piece)); - Ok(Some(piece)) - } - AcquireResult::NoneAvailable => Ok(None), - } - }) - .transpose() - .map(|r| r.flatten()); - - // Process steal notification outside the peer lock to avoid deadlock - if let Some((from_peer, piece)) = steal_info { - self.state.peers.on_steal(from_peer, self.addr, piece); - } - - result - } - - fn on_download_request(&self, request: Request) -> anyhow::Result<()> { - if self.state.torrent().options.disable_upload() { - anyhow::bail!("upload disabled, but peer requested a piece") - } - - let piece_index = match self.state.lengths.validate_piece_index(request.index) { - Some(p) => p, - None => { - anyhow::bail!( - "received {:?}, but it is not a valid chunk request (piece index is invalid). Ignoring.", - request - ); - } - }; - - let chunk_info = match self.state.lengths.chunk_info_from_received_data( - piece_index, - request.begin, - request.length, - ) { - Some(d) => d, - None => { - anyhow::bail!( - "received {:?}, but it is not a valid chunk request (chunk data is invalid). Ignoring.", - request - ); - } - }; - - if !self - .state - .lock_read("is_chunk_ready_to_upload") - .get_chunks()? - .is_chunk_ready_to_upload(&chunk_info) - { - anyhow::bail!( - "got request for a chunk that is not ready to upload. chunk {:?}", - &chunk_info - ); - } - - self.state - .ratelimit_upload_tx - .send((self.tx.clone(), chunk_info))?; - Ok(()) - } - - fn on_have(&self, have: u32) { - self.state - .peers - .with_live_mut(self.addr, "on_have", |live| { - // If bitfield wasn't allocated yet, let's do it. Some clients start empty so they never - // send bitfields. - if live.bitfield.is_empty() { - live.bitfield = make_piece_bitfield(&self.state.lengths); - } - match live.bitfield.get_mut(have as usize) { - Some(mut v) => *v = true, - None => { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - addr = ?self.addr, - "received have {} out of range", - have - ); - return; - } - }; - trace!("updated bitfield with have={}", have); - if let Some(true) = live - .bitfield - .get(..self.state.lengths.total_pieces() as usize) - .map(|s| s.all()) - { - debug!("peer has full torrent"); - } - }); - self.on_bitfield_notify.notify_waiters(); - } - - fn on_bitfield(&self, bitfield: ByteBufOwned) -> anyhow::Result<()> { - if bitfield.as_ref().len() != self.state.lengths.piece_bitfield_bytes() { - anyhow::bail!( - "dropping peer as its bitfield has unexpected size. Got {}, expected {}", - bitfield.as_ref().len(), - self.state.lengths.piece_bitfield_bytes(), - ); - } - let bf = BF::from_boxed_slice(bitfield.0.to_vec().into_boxed_slice()); - if let Some(true) = bf - .get(..self.state.lengths.total_pieces() as usize) - .map(|s| s.all()) - { - debug!("peer has full torrent"); - } - self.state.peers.update_bitfield(self.addr, bf); - self.on_bitfield_notify.notify_waiters(); - Ok(()) - } - - async fn wait_for_any_notify(&self, notify: &Notify, check: impl Fn() -> bool) { - // To remove possibility of races, we first grab a token, then check - // if we need it, and only if so, await. - let notified = notify.notified(); - if check() { - return; - } - notified.await; - } - - async fn wait_for_bitfield(&self) { - self.wait_for_any_notify(&self.on_bitfield_notify, || { - self.state - .peers - .with_live(self.addr, |live| !live.bitfield.is_empty()) - .unwrap_or_default() - }) - .await; - } - - async fn wait_for_unchoke(&self) { - self.wait_for_any_notify(&self.unchoke_notify, || { - !self.lock_read("wait_for_unchoke:i_am_choked").i_am_choked - }) - .await; - } - - // The job of this is to request chunks and also to keep peer alive. - // The moment this ends, the peer is disconnected. - pub(crate) async fn task_peer_chunk_requester(&self) -> crate::Result<()> { - let handle = self.addr; - self.wait_for_bitfield().await; - - let mut update_interest = { - let mut current = false; - move |h: &PeerHandler, new_value: bool| -> crate::Result<()> { - if new_value != current { - h.tx.send(if new_value { - WriterRequest::Message(Message::Interested) - } else { - WriterRequest::Message(Message::NotInterested) - }) - .ok() - .ok_or(Error::PeerTaskDead)?; - current = new_value; - } - Ok(()) - } - }; - - loop { - // If we have full torrent, we don't need to request more pieces. - // However we might still need to seed them to the peer. - if self.state.is_finished_and_no_active_streams() { - update_interest(self, false)?; - if self - .state - .peers - .is_peer_not_interested_and_has_full_torrent( - self.addr, - self.state.lengths.total_pieces() as usize, - ) - { - debug!("nothing left to do, neither of us is interested, disconnecting peer"); - self.tx - .send(WriterRequest::Disconnect(Ok(()))) - .ok() - .ok_or(Error::PeerTaskDead)?; - // wait until the receiver gets the message so that it doesn't finish with an error. - tokio::time::sleep(Duration::from_millis(100)).await; - return Ok(()); - } else { - // TODO: wait for a notification of interest, e.g. update of selected files or new streams or change - // in peer interest. - tokio::time::sleep(Duration::from_secs(5)).await; - continue; - } - } - - update_interest(self, true)?; - aframe!(self.wait_for_unchoke()).await; - - // Acquire a piece using the strategy: try steal (10x) -> reserve -> steal (3x). - let new_piece_notify = self.state.new_pieces_notify.notified(); - let next = match self.acquire_next_piece()? { - Some(next) => next, - None => { - debug!("no pieces to request"); - match aframe!(tokio::time::timeout( - // Half of default rw timeout not to race with it. - Duration::from_secs(5), - new_piece_notify - )) - .await - { - Ok(()) => debug!("woken up, new pieces might be available"), - Err(_) => debug!("woken up by sleep timer"), - } - continue; - } - }; - - for chunk in self.state.lengths.iter_chunk_infos(next) { - let request = Request { - index: next.get(), - begin: chunk.offset, - length: chunk.size, - }; - - match self - .state - .peers - .with_live_mut(handle, "add chunk request", |live| { - live.inflight_requests.insert(chunk) - }) { - Some(true) => {} - Some(false) => { - // This request was already in-flight for this peer for this chunk. - // This might happen in theory, but not very likely. - // - // Example: - // someone stole a piece from us, and then died, the piece became "needed" again, and we reserved it - // all before the piece request was processed by us. - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - addr = ?self.addr, - "we already requested {:?} previously", - chunk - ); - continue; - } - // peer died - None => return Ok(()), - }; - - let Some(request_length) = NonZeroU32::new(request.length) else { - warn!( - addr = ?self.addr, - "peer sent request with zero length, ignoring" - ); - continue; - }; - - self.state - .ratelimits - .prepare_for_download(request_length) - .await?; - - if let Some(session) = self.state.torrent().session.upgrade() { - session - .ratelimits - .prepare_for_download(request_length) - .await?; - } - - loop { - match aframe!(tokio::time::timeout( - Duration::from_secs(5), - aframe!(self.requests_sem.acquire()) - )) - .await - { - Ok(acq) => break acq?.forget(), - Err(_) => continue, - }; - } - - if self - .tx - .send(WriterRequest::Message(Message::Request(request))) - .is_err() - { - return Ok(()); - } - } - } - } - - fn on_i_am_choked(&self) { - self.lock_write("i_am_choked = true").i_am_choked = true; - } - - fn on_peer_interested(&self) { - trace!("peer is interested"); - self.state.peers.mark_peer_interested(self.addr, true); - } - - fn on_i_am_unchoked(&self) { - trace!("we are unchoked"); - self.lock_write("i_am_choked = false").i_am_choked = false; - self.unchoke_notify.notify_waiters(); - // 128 should be more than enough to maintain 100mbps - // for a single peer that has 100ms ping - // https://www.desmos.com/calculator/x3szur87ps - self.requests_sem.add_permits(128); - } - - async fn on_received_piece(&self, piece: Piece>) -> anyhow::Result<()> { - let piece_index = self - .state - .lengths - .validate_piece_index(piece.index) - .with_context(|| format!("peer sent an invalid piece {}", piece.index))?; - let chunk_info = match self.state.lengths.chunk_info_from_received_data( - piece_index, - piece.begin, - piece.len().try_into().context("bug")?, - ) { - Some(i) => i, - None => { - anyhow::bail!("peer sent us an invalid piece {:?}", &piece,); - } - }; - - self.requests_sem.add_permits(1); - - // Peer chunk/byte counters. - self.counters - .fetched_bytes - .fetch_add(piece.len() as u64, Ordering::Relaxed); - self.counters.fetched_chunks.fetch_add(1, Ordering::Relaxed); - - self.state - .peers - .with_live_mut(self.addr, "inflight_requests.remove", |h| { - if !h.inflight_requests.remove(&chunk_info) { - anyhow::bail!( - "peer sent us a piece we did not ask. Requested pieces: {:?}. Got: {:?}", - &h.inflight_requests, - &piece, - ); - } - Ok(()) - }) - .context("peer not found")??; - - // This one is used to calculate download speed. - self.state - .stats - .fetched_bytes - .fetch_add(piece.len() as u64, Ordering::Relaxed); - self.state - .session_stats - .counters - .fetched_bytes - .fetch_add(piece.len() as u64, Ordering::Relaxed); - - fn write_to_disk( - state: &TorrentStateLive, - addr: PeerHandle, - counters: &AtomicPeerCounters, - piece: &Piece>, - chunk_info: &ChunkInfo, - ) -> anyhow::Result<()> { - let index = piece.index; - - // If someone stole the piece by now, ignore it. - // However if they didn't, don't let them steal it while we are writing. - // So that by the time we are done writing AND if it was the last piece, - // we can actually checksum etc. - // Otherwise it might get into some weird state. - let ppl_guard = { - let g = state.lock_read("check_steal"); - - let ppl = state - .per_piece_locks - .get(piece.index as usize) - .map(|l| l.read()); - - match g.get_pieces()?.get_inflight(chunk_info.piece_index) { - Some(inflight) if inflight.peer == addr => {} - Some(inflight) => { - debug!( - "in-flight piece {} was stolen by {}, ignoring", - chunk_info.piece_index, inflight.peer - ); - return Ok(()); - } - None => { - debug!( - "in-flight piece {} not found. it was probably completed by someone else", - chunk_info.piece_index - ); - return Ok(()); - } - }; - - ppl - }; - - // While we hold per piece lock, noone can steal it. - // So we can proceed writing knowing that the piece is ours now and will still be by the time - // the write is finished. - // - - if !cfg!(feature = "_disable_disk_write_net_benchmark") { - match state.file_ops().write_chunk(addr, piece, chunk_info) { - Ok(()) => {} - Err(e) => { - error!( - id = state.shared.id, - info_hash = ?state.shared.info_hash, - "FATAL: error writing chunk to disk: {e:#}" - ); - return state.on_fatal_error(e); - } - }; - } - - let full_piece_download_time = { - let mut g = state.lock_write("mark_chunk_downloaded"); - let chunk_marking_result = g.get_pieces_mut()?.mark_chunk_downloaded(piece); - trace!(?piece, chunk_marking_result=?chunk_marking_result); - - match chunk_marking_result { - Some(ChunkMarkingResult::Completed) => { - trace!("piece={} done, will write and checksum", piece.index); - // Remove from inflight to prevent others from stealing it during hash check. - g.get_pieces_mut()?.take_inflight(chunk_info.piece_index) - } - Some(ChunkMarkingResult::PreviouslyCompleted) => { - // TODO: we might need to send cancellations here. - debug!("piece={} was done by someone else, ignoring", piece.index); - return Ok(()); - } - Some(ChunkMarkingResult::NotCompleted) => None, - None => { - anyhow::bail!( - "bogus data received: {:?}, cannot map this to a chunk, dropping peer", - piece - ); - } - } - }; - - // We don't care about per piece lock anymore, as it's removed from inflight pieces. - // It shouldn't impact perf anyway, but dropping just in case. - drop(ppl_guard); - - let full_piece_download_time = match full_piece_download_time { - Some(t) => t, - None => return Ok(()), - }; - - match state - .file_ops() - .check_piece(chunk_info.piece_index) - .with_context(|| format!("error checking piece={index}"))? - { - true => { - { - let mut g = state.lock_write("mark_piece_downloaded"); - g.get_pieces_mut()? - .mark_piece_hash_ok(chunk_info.piece_index); - } - - // Global piece counters. - let piece_len = state.lengths.piece_length(chunk_info.piece_index) as u64; - state - .stats - .downloaded_and_checked_bytes - // This counter is used to compute "is_finished", so using - // stronger ordering. - .fetch_add(piece_len, Ordering::Release); - state - .stats - .downloaded_and_checked_pieces - // This counter is used to compute "is_finished", so using - // stronger ordering. - .fetch_add(1, Ordering::Release); - state - .stats - .have_bytes - .fetch_add(piece_len, Ordering::Relaxed); - #[allow(clippy::cast_possible_truncation)] - state.stats.total_piece_download_ms.fetch_add( - full_piece_download_time.as_millis() as u64, - Ordering::Relaxed, - ); - - // Per-peer piece counters. - counters.on_piece_completed(piece_len, full_piece_download_time); - state.peers.reset_peer_backoff(addr); - - trace!(piece = index, "successfully downloaded and verified"); - - state.on_piece_completed(chunk_info.piece_index)?; - - state.transmit_haves(chunk_info.piece_index); - } - false => { - warn!( - id = state.shared.id, - info_hash = ?state.shared.info_hash, - ?addr, - "checksum for piece={} did not validate. disconnecting peer.", index - ); - state - .lock_write("mark_piece_broken") - .get_pieces_mut()? - .mark_piece_hash_failed(chunk_info.piece_index); - state.new_pieces_notify.notify_waiters(); - anyhow::bail!("i am probably a bogus peer. dying.") - } - }; - Ok(()) - } - - self.state - .shared - .spawner - .block_in_place_with_semaphore(|| { - write_to_disk(&self.state, self.addr, &self.counters, &piece, &chunk_info) - }) - .await - .with_context(|| format!("error processing received chunk {chunk_info:?}"))?; - - Ok(()) - } - - fn send_metadata_piece(&self, piece_id: u32) -> anyhow::Result<()> { - let data = &self.state.metadata.info_bytes; - let metadata_size = data.len(); - if metadata_size == 0 { - anyhow::bail!("peer requested for info metadata but we don't have it") - } - let total_pieces: usize = (metadata_size as u64) - .div_ceil(CHUNK_SIZE as u64) - .try_into()?; - - if piece_id as usize > total_pieces { - bail!("piece out of bounds") - } - - let offset = piece_id * CHUNK_SIZE; - let end = (offset + CHUNK_SIZE).min(data.len().try_into()?); - let total_size: u32 = data - .len() - .try_into() - .context("can't send metadata: len doesn't fit into u32")?; - let data = data.slice(offset as usize..end as usize); - - self.tx - .send(WriterRequest::UtMetadata(UtMetadata::Data( - UtMetadataData::from_bytes(piece_id, total_size, data.into()), - ))) - .context("error sending UtMetadata: channel closed")?; - Ok(()) - } - - fn on_pex_message(&self, msg: UtPex>) { - msg.dropped_peers() - .chain(msg.added_peers()) - .for_each(|peer| { - self.state - .add_peer_if_not_seen(peer.addr) - .map_err(|error| { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - ?peer, - "failed to add peer: {error:#}" - ); - error - }) - .ok(); - }); - } - - fn on_holepunch_message(&self, msg: HolepunchMessage) { - if self.state.metadata.info.info().private { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - "received noncompliant holepunch message from {}, ignoring (private torrent)", - self.addr - ); - return; - } - - match msg.msg_type { - HolepunchMsgType::Rendezvous => { - let target_addr = msg.addr; - // Try to relay Connect to the target peer - let relayed = self.state.peers.with_live(target_addr, |live| { - // Send Connect message to the target telling it about the initiator - let connect_to_target = HolepunchMessage { - msg_type: HolepunchMsgType::Connect, - addr: self.addr, - error_code: None, - }; - let _ = live.tx.send(WriterRequest::UtHolepunch(connect_to_target)); - - // Send Connect message back to the initiator telling it about the target - let connect_to_initiator = HolepunchMessage { - msg_type: HolepunchMsgType::Connect, - addr: target_addr, - error_code: None, - }; - let _ = self - .tx - .send(WriterRequest::UtHolepunch(connect_to_initiator)); - }); - - if relayed.is_none() { - // Target peer is not connected, send Error back to initiator - let error_msg = HolepunchMessage { - msg_type: HolepunchMsgType::Error, - addr: target_addr, - error_code: Some(HolepunchErrorCode::NotConnected), - }; - let _ = self.tx.send(WriterRequest::UtHolepunch(error_msg)); - } - } - HolepunchMsgType::Connect => { - // A rendezvous peer is telling us to connect to this peer - let peer_addr = msg.addr; - self.state - .add_peer_if_not_seen(peer_addr) - .map_err(|error| { - warn!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - addr = %peer_addr, - "holepunch: failed to add peer: {error:#}" - ); - error - }) - .ok(); - } - HolepunchMsgType::Error => { - debug!( - id = self.state.shared.id, - info_hash = ?self.state.shared.info_hash, - addr = %msg.addr, - error_code = ?msg.error_code, - "holepunch error received, ignoring" - ); - } - } - } - - fn lock_read( - &self, - reason: &'static str, - ) -> TimedExistence> { - TimedExistence::new(timeit(reason, || self._locked.read()), reason) - } - fn lock_write( - &self, - reason: &'static str, - ) -> TimedExistence> { - TimedExistence::new(timeit(reason, || self._locked.write()), reason) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peers/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peers/mod.rs deleted file mode 100644 index 1ee44198..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peers/mod.rs +++ /dev/null @@ -1,525 +0,0 @@ -use std::{collections::HashSet, net::SocketAddr, sync::Arc, time::Duration}; - -use dashmap::DashMap; -use librtbit_core::lengths::ValidPieceIndex; -use parking_lot::RwLock; -use peer_binary_protocol::{Message, Request}; - -use tracing::debug; - -use crate::{ - Error, - peer_connection::WriterRequest, - torrent_state::utils::{TimedExistence, atomic_inc}, - type_aliases::{BF, PeerHandle}, -}; - -use self::stats::{AggregatePeerStats, AggregatePeerStatsAtomic}; - -use super::peer::{LivePeerState, Peer, PeerRx, PeerState, PeerTx}; - -pub mod stats; - -pub(crate) struct PeerStates { - pub session_stats: Arc, - - // This keeps track of live addresses we connected to, for PEX. - pub live_outgoing_peers: RwLock>, - pub stats: AggregatePeerStatsAtomic, - pub states: DashMap, -} - -impl Drop for PeerStates { - fn drop(&mut self) { - for (_, p) in std::mem::take(&mut self.states).into_iter() { - p.destroy(self); - } - } -} - -impl PeerStates { - pub fn stats(&self) -> AggregatePeerStats { - self.stats.snapshot() - } - - pub fn add_if_not_seen(&self, addr: SocketAddr) -> Option { - use dashmap::mapref::entry::Entry; - match self.states.entry(addr) { - Entry::Occupied(_) => None, - Entry::Vacant(vac) => { - vac.insert(Peer::new_with_outgoing_address(addr)); - atomic_inc(&self.stats.queued); - atomic_inc(&self.session_stats.queued); - - atomic_inc(&self.stats.seen); - atomic_inc(&self.session_stats.seen); - Some(addr) - } - } - } - pub fn with_peer(&self, addr: PeerHandle, f: impl FnOnce(&Peer) -> R) -> Option { - self.states.get(&addr).map(|e| f(e.value())) - } - - pub fn with_peer_mut( - &self, - addr: PeerHandle, - reason: &'static str, - f: impl FnOnce(&mut Peer) -> R, - ) -> Option { - use crate::torrent_state::utils::timeit; - timeit(reason, || self.states.get_mut(&addr)) - .map(|e| f(TimedExistence::new(e, reason).value_mut())) - } - - pub fn with_live(&self, addr: PeerHandle, f: impl FnOnce(&LivePeerState) -> R) -> Option { - self.with_peer(addr, |peer| peer.get_live().map(f)) - .flatten() - } - - pub fn with_live_mut( - &self, - addr: PeerHandle, - reason: &'static str, - f: impl FnOnce(&mut LivePeerState) -> R, - ) -> Option { - self.with_peer_mut(addr, reason, |peer| peer.get_live_mut().map(f)) - .flatten() - } - - pub fn drop_peer(&self, handle: PeerHandle) -> Option { - let p = self.states.remove(&handle).map(|r| r.1)?; - let s = p.get_state(); - self.stats.dec(s); - self.session_stats.dec(s); - - Some(p) - } - - pub fn is_peer_not_interested_and_has_full_torrent( - &self, - handle: PeerHandle, - total_pieces: usize, - ) -> bool { - self.with_live(handle, |live| { - !live.peer_interested && live.has_full_torrent(total_pieces) - }) - .unwrap_or(false) - } - - pub fn mark_peer_interested(&self, handle: PeerHandle, is_interested: bool) -> Option { - self.with_live_mut(handle, "mark_peer_interested", |live| { - let prev = live.peer_interested; - live.peer_interested = is_interested; - prev - }) - } - - pub fn update_bitfield(&self, handle: PeerHandle, bitfield: BF) -> Option<()> { - self.with_live_mut(handle, "update_bitfield", |live| { - live.bitfield = bitfield; - }) - } - - pub fn mark_peer_connecting(&self, h: PeerHandle) -> crate::Result<(PeerRx, PeerTx)> { - let rx = self - .with_peer_mut(h, "mark_peer_connecting", |peer| { - peer.idle_to_connecting(self) - .ok_or(Error::BugInvalidPeerState) - }) - .ok_or(Error::BugPeerNotFound)??; - Ok(rx) - } - - pub fn reset_peer_backoff(&self, handle: PeerHandle) { - self.with_peer_mut(handle, "reset_peer_backoff", |p| { - p.stats.reset_backoff(); - }); - } - - pub fn mark_peer_not_needed(&self, handle: PeerHandle) -> Option { - let prev = self.with_peer_mut(handle, "mark_peer_not_needed", |peer| { - peer.set_not_needed(self) - })?; - Some(prev) - } - - #[cfg(test)] - fn new_for_test() -> Self { - Self { - session_stats: Arc::new(AggregatePeerStatsAtomic::default()), - live_outgoing_peers: Default::default(), - stats: Default::default(), - states: Default::default(), - } - } - - pub(crate) fn on_steal( - &self, - from_peer: SocketAddr, - to_peer: SocketAddr, - stolen_idx: ValidPieceIndex, - ) { - self.with_peer(to_peer, |p| { - atomic_inc(&p.stats.counters.times_i_stole); - }); - self.with_peer(from_peer, |p| { - atomic_inc(&p.stats.counters.times_stolen_from_me); - }); - self.stats.inc_steals(); - self.session_stats.inc_steals(); - - self.with_live_mut(from_peer, "send_cancellations", |live| { - let tx = &live.tx; - live.inflight_requests.retain(|req| { - if req.piece_index == stolen_idx { - let _ = tx.send(WriterRequest::Message(Message::Cancel(Request { - index: stolen_idx.get(), - begin: req.offset, - length: req.size, - }))); - false - } else { - true - } - }); - }); - } - - /// Remove peers that have been in Dead or NotNeeded state for longer than - /// `retention`. Returns the number of peers pruned. - pub fn prune_dead_peers(&self, retention: Duration) -> usize { - let now = std::time::Instant::now(); - let mut pruned = 0usize; - self.states.retain(|_addr, peer| { - let dominated = matches!(peer.get_state(), PeerState::Dead | PeerState::NotNeeded); - if dominated && now.duration_since(peer.last_state_change) > retention { - // Decrement counters before removing. - for counter in [&self.session_stats, &self.stats] { - counter.dec(peer.get_state()); - } - pruned += 1; - false - } else { - true - } - }); - if pruned > 0 { - debug!( - pruned, - remaining = self.states.len(), - "pruned dead/not-needed peers" - ); - } - pruned - } -} - -#[cfg(test)] -mod tests { - use std::net::{Ipv4Addr, SocketAddr}; - use std::sync::Arc; - use std::time::Duration; - - use super::*; - - fn make_peer_states() -> PeerStates { - PeerStates { - session_stats: Arc::new(AggregatePeerStatsAtomic::default()), - stats: AggregatePeerStatsAtomic::default(), - states: DashMap::new(), - live_outgoing_peers: RwLock::new(HashSet::new()), - } - } - - fn addr(port: u16) -> SocketAddr { - SocketAddr::new(Ipv4Addr::new(127, 0, 0, 1).into(), port) - } - - #[test] - fn test_dead_peer_pruning_removes_stale_dead_peers() { - let ps = make_peer_states(); - - // Add 5 peers, mark them Dead - for port in 1..=5 { - let a = addr(port); - ps.add_if_not_seen(a); - ps.with_peer_mut(a, "set_dead", |peer| { - peer.set_state(PeerState::Dead, &ps); - }); - } - assert_eq!(ps.states.len(), 5); - - // With a retention of 0, all dead peers should be pruned immediately - let pruned = ps.prune_dead_peers(Duration::ZERO); - assert_eq!(pruned, 5); - assert_eq!(ps.states.len(), 0); - } - - #[test] - fn test_dead_peer_pruning_removes_stale_not_needed_peers() { - let ps = make_peer_states(); - - for port in 1..=3 { - let a = addr(port); - ps.add_if_not_seen(a); - ps.with_peer_mut(a, "set_not_needed", |peer| { - peer.set_not_needed(&ps); - }); - } - assert_eq!(ps.states.len(), 3); - - let pruned = ps.prune_dead_peers(Duration::ZERO); - assert_eq!(pruned, 3); - assert_eq!(ps.states.len(), 0); - } - - #[test] - fn test_dead_peer_pruning_preserves_recent_peers() { - let ps = make_peer_states(); - - // Add peers and mark dead - for port in 1..=5 { - let a = addr(port); - ps.add_if_not_seen(a); - ps.with_peer_mut(a, "set_dead", |peer| { - peer.set_state(PeerState::Dead, &ps); - }); - } - - // With 1 hour retention, nothing should be pruned (peers just became dead) - let pruned = ps.prune_dead_peers(Duration::from_secs(3600)); - assert_eq!(pruned, 0); - assert_eq!(ps.states.len(), 5); - } - - #[test] - fn test_dead_peer_pruning_preserves_queued_and_connecting() { - let ps = make_peer_states(); - - // Queued peer (default state from add_if_not_seen) - let queued_addr = addr(1); - ps.add_if_not_seen(queued_addr); - - // Dead peer - let dead_addr = addr(2); - ps.add_if_not_seen(dead_addr); - ps.with_peer_mut(dead_addr, "set_dead", |peer| { - peer.set_state(PeerState::Dead, &ps); - }); - - assert_eq!(ps.states.len(), 2); - - // Zero retention should only prune the dead peer, not the queued one - let pruned = ps.prune_dead_peers(Duration::ZERO); - assert_eq!(pruned, 1); - assert_eq!(ps.states.len(), 1); - assert!(ps.states.contains_key(&queued_addr)); - assert!(!ps.states.contains_key(&dead_addr)); - } - - #[test] - fn test_dead_peer_pruning_counters_are_decremented() { - let ps = make_peer_states(); - - // Add 3 peers, mark them dead - for port in 1..=3 { - let a = addr(port); - ps.add_if_not_seen(a); - ps.with_peer_mut(a, "set_dead", |peer| { - peer.set_state(PeerState::Dead, &ps); - }); - } - - let stats_before = ps.stats.snapshot(); - assert_eq!(stats_before.dead, 3); - - ps.prune_dead_peers(Duration::ZERO); - - let stats_after = ps.stats.snapshot(); - assert_eq!(stats_after.dead, 0); - } - - #[test] - fn test_semaphore_permit_returned_on_drop() { - // Verify that OwnedSemaphorePermit releases on drop (RAII behavior). - // This confirms our _permit_guard pattern works correctly. - let sem = Arc::new(tokio::sync::Semaphore::new(2)); - - // Acquire 2 permits - let p1 = sem.clone().try_acquire_owned().unwrap(); - let p2 = sem.clone().try_acquire_owned().unwrap(); - assert!(sem.clone().try_acquire_owned().is_err()); - - // Drop one - should free a slot - drop(p1); - assert!(sem.clone().try_acquire_owned().is_ok()); - - // Rename to _guard (simulating our pattern) and let it drop naturally - let _guard = p2; - // p2 is moved into _guard, will be dropped at end of scope - - drop(_guard); - assert_eq!(sem.available_permits(), 2); - } - - /// Test that aggregate stats correctly track peer state transitions. - #[test] - fn test_peer_stats_tracking() { - let peers = PeerStates::new_for_test(); - - // Add 3 peers (all start in Queued state). - for port in 1..=3 { - peers.add_if_not_seen(addr(port)); - } - let stats = peers.stats(); - assert_eq!(stats.queued, 3); - assert_eq!(stats.live, 0); - assert_eq!(stats.dead, 0); - } - - /// Test that peers in Dead state can be transitioned to Queued. - #[test] - fn test_dead_peer_requeue() { - let peers = PeerStates::new_for_test(); - let a = addr(1); - peers.add_if_not_seen(a); - - // Transition to Dead. - peers.with_peer_mut(a, "set_dead", |p| { - p.set_state(PeerState::Dead, &peers); - }); - - let stats = peers.stats(); - assert_eq!(stats.dead, 1); - assert_eq!(stats.queued, 0); - - // Re-queue the dead peer (simulating what requeue_dead_peers does). - peers.with_peer_mut(a, "requeue", |p| { - if matches!(p.get_state(), PeerState::Dead) { - p.stats.reset_backoff(); - p.set_state(PeerState::Queued, &peers); - } - }); - - let stats = peers.stats(); - assert_eq!(stats.dead, 0); - assert_eq!(stats.queued, 1); - } - - /// Test that peers in Dead state are retained in the states table - /// and can be recovered via backoff reset (simulating what happens - /// when backoff exhaustion triggers our "frozen peer" reset logic). - #[test] - fn test_peers_not_permanently_dropped() { - let peers = PeerStates::new_for_test(); - let a = addr(1); - peers.add_if_not_seen(a); - - // Transition to Dead (as on_peer_died would do). - peers.with_peer_mut(a, "set_dead", |p| { - p.set_state(PeerState::Dead, &peers); - }); - - // The peer should exist in the states table in Dead state. - assert!( - peers.states.contains_key(&a), - "peer should still exist in Dead state" - ); - - let stats = peers.stats(); - assert_eq!(stats.dead, 1); - - // Simulate what our code does on backoff exhaustion: reset backoff - // and schedule a long retry rather than dropping. - peers.with_peer_mut(a, "reset_after_exhaust", |p| { - p.stats.reset_backoff(); - assert!( - p.stats.backoff.next().is_some(), - "backoff should produce delays after reset" - ); - }); - - // Peer should still be in the table. - assert!( - peers.states.contains_key(&a), - "peer should still exist after backoff reset" - ); - } - - /// Test that after setting peers to Dead and calling reset_backoff, - /// they can be re-queued (simulating the rediscovery trigger behavior). - #[test] - fn test_backoff_reset_on_rediscovery() { - let peers = PeerStates::new_for_test(); - - // Add several peers and set them all to Dead with advanced backoffs. - for port in 1..=5 { - let a = addr(port); - peers.add_if_not_seen(a); - peers.with_peer_mut(a, "set_dead", |p| { - p.set_state(PeerState::Dead, &peers); - // Advance backoff significantly. - for _ in 0..20 { - p.stats.backoff.next(); - } - }); - } - - let stats = peers.stats(); - assert_eq!(stats.dead, 5); - assert_eq!(stats.queued, 0); - - // Simulate rediscovery: reset backoffs and re-queue all dead peers. - let mut requeued = 0u32; - for mut entry in peers.states.iter_mut() { - let peer = entry.value_mut(); - if matches!(peer.get_state(), PeerState::Dead) { - peer.stats.reset_backoff(); - peer.set_state(PeerState::Queued, &peers); - requeued += 1; - } - } - - assert_eq!(requeued, 5); - let stats = peers.stats(); - assert_eq!(stats.dead, 0); - assert_eq!(stats.queued, 5); - - // Verify all peers have functional backoffs. - for mut entry in peers.states.iter_mut() { - let peer = entry.value_mut(); - assert!( - peer.stats.backoff.next().is_some(), - "peer should have functional backoff after reset" - ); - } - } - - /// Test that the active peer count (live + connecting) is correctly computed. - #[test] - fn test_active_peer_count() { - let peers = PeerStates::new_for_test(); - - // Initially: no peers. - let stats = peers.stats(); - assert_eq!(stats.live + stats.connecting, 0); - - // Add 3 peers (Queued state). - for port in 1..=3 { - peers.add_if_not_seen(addr(port)); - } - - // All in Queued, none are "active". - let stats = peers.stats(); - assert_eq!(stats.live + stats.connecting, 0); - assert_eq!(stats.queued, 3); - - // Transition peer 1 to Connecting. - let a1 = addr(1); - peers.mark_peer_connecting(a1).unwrap(); - let stats = peers.stats(); - assert_eq!(stats.connecting, 1); - assert_eq!(stats.live + stats.connecting, 1); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peers/stats/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peers/stats/mod.rs deleted file mode 100644 index de83b298..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/peers/stats/mod.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::sync::atomic::AtomicU32; - -use crate::{ - stream_connect::ConnectionKind, - torrent_state::{ - live::peer::PeerState, - peer::LivePeerState, - utils::{atomic_dec, atomic_inc}, - }, -}; - -gen_stats!(AggregatePeerStatsAtomic AggregatePeerStats, [ - queued u32, - connecting u32, - live u32, - live_tcp u32, - live_utp u32, - live_socks u32, - seen u32, - dead u32, - not_needed u32, - steals u32 -], []); - -impl AggregatePeerStatsAtomic { - fn counter(&self, state: &PeerState) -> &AtomicU32 { - match state { - PeerState::Connecting(_) => &self.connecting, - PeerState::Live(_) => &self.live, - PeerState::Queued => &self.queued, - PeerState::Dead => &self.dead, - PeerState::NotNeeded => &self.not_needed, - } - } - - fn live_kind_counter(&self, l: &LivePeerState) -> &AtomicU32 { - match l.connection_kind { - ConnectionKind::Tcp => &self.live_tcp, - ConnectionKind::Utp => &self.live_utp, - ConnectionKind::Socks => &self.live_socks, - } - } - - pub(crate) fn inc(&self, state: &PeerState) { - if let PeerState::Live(l) = state { - atomic_inc(self.live_kind_counter(l)); - } - atomic_inc(self.counter(state)); - } - - pub(crate) fn dec(&self, state: &PeerState) { - if let PeerState::Live(l) = state { - atomic_dec(self.live_kind_counter(l)); - } - atomic_dec(self.counter(state)); - } - - pub(crate) fn incdec(&self, old: &PeerState, new: &PeerState) { - self.dec(old); - self.inc(new); - } - - pub fn inc_steals(&self) { - atomic_inc(&self.steals); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/atomic.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/atomic.rs deleted file mode 100644 index c16ed508..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/atomic.rs +++ /dev/null @@ -1,11 +0,0 @@ -use std::sync::atomic::AtomicU64; - -#[derive(Default, Debug)] -pub struct AtomicStats { - pub have_bytes: AtomicU64, - pub downloaded_and_checked_bytes: AtomicU64, - pub downloaded_and_checked_pieces: AtomicU64, - pub uploaded_bytes: AtomicU64, - pub fetched_bytes: AtomicU64, - pub total_piece_download_ms: AtomicU64, -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/mod.rs deleted file mode 100644 index 1f2b6575..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod atomic; -pub mod snapshot; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/snapshot.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/snapshot.rs deleted file mode 100644 index f8ef80c8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/stats/snapshot.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::time::Duration; - -use serde::Serialize; - -use crate::torrent_state::live::peers::stats::AggregatePeerStats; - -#[derive(Debug, Serialize, Default)] -pub struct StatsSnapshot { - pub downloaded_and_checked_bytes: u64, - - pub fetched_bytes: u64, - pub uploaded_bytes: u64, - - pub downloaded_and_checked_pieces: u64, - pub total_piece_download_ms: u64, - pub peer_stats: AggregatePeerStats, -} - -impl StatsSnapshot { - pub fn average_piece_download_time(&self) -> Option { - let d = self.downloaded_and_checked_pieces; - let t = self.total_piece_download_ms; - if d == 0 { - return None; - } - Some(Duration::from_secs_f64(t as f64 / d as f64 / 1000f64)) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/tasks.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/tasks.rs deleted file mode 100644 index 526047f8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/live/tasks.rs +++ /dev/null @@ -1,407 +0,0 @@ -use std::{ - collections::HashSet, - net::{IpAddr, SocketAddr}, - num::NonZeroU32, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use parking_lot::RwLock; -use peer_binary_protocol::extended; -use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc::Receiver}; -use tracing::{Instrument, debug, debug_span, trace}; - -use crate::{ - Error, - peer_connection::{PeerConnection, PeerConnectionOptions, WriterRequest}, - session::CheckedIncomingConnection, -}; - -use super::{ - TorrentStateLive, - peer::{PeerRx, PeerTx, stats::atomic::PeerCountersAtomic as AtomicPeerCounters}, - peer_handler::{PeerHandler, PeerHandlerLocked}, -}; - -impl TorrentStateLive { - pub(crate) async fn task_upload_scheduler( - self: Arc, - mut rx: tokio::sync::mpsc::UnboundedReceiver<( - tokio::sync::mpsc::UnboundedSender, - librtbit_core::lengths::ChunkInfo, - )>, - ) -> crate::Result<()> { - while let Some((tx, ci)) = rx.recv().await { - tokio::select! { - _ = tx.closed() => { - continue; - } - res = self.ratelimits.prepare_for_upload(NonZeroU32::new(ci.size).unwrap()) => { - res?; - } - }; - if let Some(session) = self.shared.session.upgrade() { - tokio::select! { - _ = tx.closed() => { - continue; - } - res = session.ratelimits.prepare_for_upload(NonZeroU32::new(ci.size).unwrap()) => { - res?; - } - } - } - let _ = tx.send(WriterRequest::ReadChunkRequest(ci)); - } - Ok(()) - } - - pub(crate) async fn task_manage_incoming_peer( - self: Arc, - checked_peer: CheckedIncomingConnection, - counters: Arc, - tx: PeerTx, - rx: PeerRx, - permit: OwnedSemaphorePermit, - ) -> crate::Result<()> { - // Hold the permit as an RAII guard so it is always released, - // even if we return early via `?` or panic. - let _permit_guard = permit; - - let handler = PeerHandler { - addr: checked_peer.addr, - incoming: true, - on_bitfield_notify: Default::default(), - unchoke_notify: Default::default(), - _locked: RwLock::new(PeerHandlerLocked { i_am_choked: true }), - requests_sem: Semaphore::new(0), - state: self.clone(), - tx, - counters, - first_message_received: AtomicBool::new(false), - cancel_token: self.cancellation_token.child_token(), - }; - let _token_guard = handler.cancel_token.clone().drop_guard(); - let options = PeerConnectionOptions { - connect_timeout: self.shared.options.peer_connect_timeout, - read_write_timeout: self.shared.options.peer_read_write_timeout, - ..Default::default() - }; - let peer_connection = PeerConnection::new( - checked_peer.addr, - self.shared.info_hash, - self.shared.peer_id, - &handler, - Some(options), - self.shared.spawner.clone(), - self.shared.connector.clone(), - ); - let requester = handler.task_peer_chunk_requester(); - - let res = tokio::select! { - r = requester => {r} - r = peer_connection.manage_peer_incoming( - rx, - checked_peer, - self.have_broadcast_tx.subscribe() - ) => {r} - }; - - match res { - // We disconnected the peer ourselves as we don't need it - Ok(()) => { - handler.on_peer_died(None)?; - } - Err(e) => { - debug!("error managing peer: {:#}", e); - handler.on_peer_died(Some(e))?; - } - }; - Ok(()) - } - - pub(crate) async fn task_manage_outgoing_peer( - self: Arc, - addr: SocketAddr, - permit: OwnedSemaphorePermit, - ) -> crate::Result<()> { - // Hold the permit as an RAII guard so it is always released, - // even if we return early via `?` or panic. - let _permit_guard = permit; - - let state = self; - let (rx, tx) = state.peers.mark_peer_connecting(addr)?; - let counters = state - .peers - .with_peer(addr, |p| p.stats.counters.clone()) - .ok_or(Error::BugPeerNotFound)?; - - let handler = PeerHandler { - addr, - incoming: false, - on_bitfield_notify: Default::default(), - unchoke_notify: Default::default(), - _locked: RwLock::new(PeerHandlerLocked { i_am_choked: true }), - requests_sem: Semaphore::new(0), - state: state.clone(), - tx, - counters, - first_message_received: AtomicBool::new(false), - cancel_token: state.cancellation_token.child_token(), - }; - let _token_guard = handler.cancel_token.clone().drop_guard(); - - let options = PeerConnectionOptions { - connect_timeout: state.shared.options.peer_connect_timeout, - read_write_timeout: state.shared.options.peer_read_write_timeout, - ..Default::default() - }; - let peer_connection = PeerConnection::new( - addr, - state.shared.info_hash, - state.shared.peer_id, - &handler, - Some(options), - state.shared.spawner.clone(), - state.shared.connector.clone(), - ); - let requester = aframe!( - handler - .task_peer_chunk_requester() - .instrument(debug_span!("chunk_requester")) - ); - let conn_manager = aframe!( - peer_connection - .manage_peer_outgoing(rx, state.have_broadcast_tx.subscribe()) - .instrument(debug_span!("peer_connection")) - ); - - handler - .counters - .outgoing_connection_attempts - .fetch_add(1, Ordering::Relaxed); - let res = tokio::select! { - r = requester => {r} - r = conn_manager => {r} - }; - - match res { - // We disconnected the peer ourselves as we don't need it - Ok(()) => { - handler.on_peer_died(None)?; - } - Err(e) => { - debug!("error managing peer: {:#}", e); - handler.on_peer_died(Some(e))?; - } - } - Ok(()) - } - - pub(crate) async fn task_peer_adder( - self: Arc, - mut peer_queue_rx: Receiver, - ) -> crate::Result<()> { - let state = self; - loop { - let addr = peer_queue_rx.recv().await.ok_or(Error::TorrentIsNotLive)?; - if state.shared.options.disable_upload() && state.is_finished_and_no_active_streams() { - debug!(?addr, "ignoring peer as we are finished"); - state.peers.mark_peer_not_needed(addr); - continue; - } - - let session = state - .shared - .session - .upgrade() - .ok_or(Error::SessionDestroyed)?; - - if session.ipv4_only && addr.is_ipv6() { - debug!(?addr, "skipping ipv6 peer (ipv4_only=true)"); - continue; - } - - if addr.port() == 0 { - debug!(?addr, "skipping peer with port 0"); - continue; - } - - if session.blocklist.has(addr.ip()) { - session - .stats - .counters - .blocked_outgoing - .fetch_add(1, Ordering::Relaxed); - debug!(?addr, "blocked outgoing connection (by the blacklist)"); - continue; - } - - if session - .allowlist - .as_ref() - .is_some_and(|l| !l.has(addr.ip())) - { - session - .stats - .counters - .blocked_outgoing - .fetch_add(1, Ordering::Relaxed); - debug!(?addr, "blocked outgoing connection (by the allowlist)"); - continue; - } - - let permit = state.peer_semaphore.clone().acquire_owned().await?; - state.spawn( - debug_span!(parent: state.shared.span.clone(), "manage_peer", peer = ?addr), - format!("[{}][addr={addr}]manage_peer", state.shared.id), - aframe!(state.clone().task_manage_outgoing_peer(addr, permit)), - ); - } - } - - /// Periodically checks the number of active peers. When the count drops - /// below [`super::MIN_PEERS_THRESHOLD`] and the torrent is not finished, - /// it triggers re-discovery by notifying `rediscovery_notify`, which in - /// turn causes the external peer adder to request new peers from DHT and - /// trackers. It also re-queues dead peers so they can be retried. - pub(crate) async fn task_peer_health_monitor(self: Arc) -> crate::Result<()> { - use super::{MIN_PEERS_THRESHOLD, PEER_HEALTH_CHECK_INTERVAL, REDISCOVERY_COOLDOWN}; - use std::time::Instant; - - let mut last_rediscovery: Option = None; - - loop { - tokio::time::sleep(PEER_HEALTH_CHECK_INTERVAL).await; - - // Don't bother with re-discovery if the torrent is finished. - if self.is_finished_and_no_active_streams() { - trace!("peer health monitor: torrent finished, skipping check"); - continue; - } - - let active_peers = self.get_active_peer_count(); - if active_peers >= MIN_PEERS_THRESHOLD { - trace!( - active_peers, - threshold = MIN_PEERS_THRESHOLD, - "peer health monitor: sufficient peers" - ); - continue; - } - - // Enforce cooldown to prevent spamming. - if let Some(last) = last_rediscovery - && last.elapsed() < REDISCOVERY_COOLDOWN - { - trace!( - active_peers, - cooldown_remaining_ms = (REDISCOVERY_COOLDOWN - last.elapsed()).as_millis(), - "peer health monitor: rediscovery cooldown active" - ); - continue; - } - - debug!( - id = self.shared.id, - active_peers, - threshold = MIN_PEERS_THRESHOLD, - "peer health monitor: low peer count, triggering re-discovery" - ); - - last_rediscovery = Some(Instant::now()); - - // Re-queue dead peers so they get another chance. - self.requeue_dead_peers(); - - // Signal to the external peer adder that we need new peers. - self.rediscovery_notify.notify_waiters(); - } - } - - pub(crate) async fn task_send_pex_to_peer( - self: Arc, - this_peer_addr: SocketAddr, - tx: PeerTx, - ) -> anyhow::Result<()> { - // As per BEP 11 we should not send more than 50 peers at once - // (here it also applies to fist message, should be OK as we anyhow really have more) - const MAX_SENT_PEERS: usize = 50; - // As per BEP 11 recommended interval is min 60 seconds - const PEX_MESSAGE_INTERVAL: Duration = Duration::from_secs(60); - - let mut connected = Vec::with_capacity(MAX_SENT_PEERS); - let mut dropped = Vec::with_capacity(MAX_SENT_PEERS); - let mut peer_view_of_live_peers = HashSet::new(); - - // Wait 10 seconds before sending the first message to assure that peer will stay with us - tokio::time::sleep(Duration::from_secs(10)).await; - - let mut interval = tokio::time::interval(PEX_MESSAGE_INTERVAL); - - loop { - interval.tick().await; - - // This task should die with the cancellation token, but check defensively just in case. - if tx.is_closed() { - return Ok(()); - } - - { - let live_peers = self.peers.live_outgoing_peers.read(); - connected.clear(); - dropped.clear(); - - connected.extend( - live_peers - .difference(&peer_view_of_live_peers) - .take(MAX_SENT_PEERS) - .copied(), - ); - dropped.extend( - peer_view_of_live_peers - .difference(&live_peers) - .take(MAX_SENT_PEERS) - .copied(), - ); - } - - trace!(connected_len = connected.len(), dropped_len = dropped.len()); - - let peer_ip_non_local = match this_peer_addr.ip() { - IpAddr::V4(a) => !a.is_loopback() && !a.is_private(), - IpAddr::V6(a) => !a.is_loopback() && !a.is_unique_local(), - }; - - let other_ip_is_local = |addr: &IpAddr| match addr { - IpAddr::V4(a) => a.is_loopback() || a.is_private(), - IpAddr::V6(a) => { - a.is_loopback() || a.is_unicast_link_local() || a.is_unique_local() - } - }; - - let filter = |addr: &SocketAddr| !(peer_ip_non_local && other_ip_is_local(&addr.ip())); - - // BEP 11 - Dont send closed if they are now in live - // it's assured by mutual exclusion of two above sets if in sent_peers_live, it cannot be in addrs_live_to_sent, - // and addrs_closed_to_sent are only filtered addresses from sent_peers_live - - if !connected.is_empty() || !dropped.is_empty() { - let pex_msg = extended::ut_pex::UtPex::from_addrs( - connected.iter().copied(), - dropped.iter().copied(), - ); - if tx.send(WriterRequest::UtPex(pex_msg)).is_err() { - return Ok(()); // Peer disconnected - } - - for addr in &dropped { - peer_view_of_live_peers.remove(addr); - } - peer_view_of_live_peers.extend(connected.iter().filter(|a| filter(a)).copied()); - } - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/mod.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/mod.rs deleted file mode 100644 index 00642d33..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/mod.rs +++ /dev/null @@ -1,796 +0,0 @@ -pub mod initializing; -pub mod live; -pub mod paused; -pub mod stats; -mod streaming; -pub mod utils; - -use std::collections::HashSet; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::Weak; -use std::sync::atomic::Ordering; -use std::time::Duration; - -use anyhow::Context; -use anyhow::bail; -use arc_swap::ArcSwapOption; -use buffers::ByteBufOwned; -use bytes::Bytes; -use futures::FutureExt; -use futures::future::BoxFuture; -use librtbit_core::hash_id::Id20; -use librtbit_core::lengths::Lengths; - -use librtbit_core::spawn_utils::spawn_with_cancel; -use librtbit_core::torrent_metainfo::ValidatedTorrentMetaV1Info; -pub use live::*; -use parking_lot::RwLock; - -use tokio::sync::Notify; -use tokio::time::timeout; -use tokio_stream::StreamExt; -use tokio_util::sync::CancellationToken; -use tracing::debug; -use tracing::debug_span; -use tracing::trace; -use tracing::warn; - -use crate::Session; -use crate::chunk_tracker::ChunkTracker; -use crate::file_info::FileInfo; -use crate::limits::LimitsConfig; -use crate::session::TorrentId; -use crate::spawn_utils::BlockingSpawner; -use crate::storage::BoxStorageFactory; -use crate::stream_connect::StreamConnector; -use crate::torrent_state::stats::LiveStats; -use crate::type_aliases::FileInfos; -use crate::type_aliases::PeerStream; - -use initializing::TorrentStateInitializing; - -use self::paused::TorrentStatePaused; -pub use self::stats::{TorrentStats, TorrentStatsState}; -pub use self::streaming::FileStream; - -// State machine transitions. -// -// - error -> initializing -// - initializing -> paused -// - paused -> live -// - live -> paused -// -// - initializing -> error -// - live -> error -pub enum ManagedTorrentState { - Initializing(Arc), - Paused(TorrentStatePaused), - Live(Arc), - Error(anyhow::Error), - - // This is used when swapping between states, outside world should never see it. - None, -} - -impl ManagedTorrentState { - pub fn name(&self) -> &'static str { - match self { - ManagedTorrentState::Initializing(_) => "initializing", - ManagedTorrentState::Paused(_) => "paused", - ManagedTorrentState::Live(_) => "live", - ManagedTorrentState::Error(_) => "error", - ManagedTorrentState::None => "", - } - } - - fn assert_paused(self) -> TorrentStatePaused { - match self { - Self::Paused(paused) => paused, - _ => panic!("Expected paused state"), - } - } - - pub(crate) fn take(&mut self) -> Self { - std::mem::replace(self, Self::None) - } -} - -pub(crate) struct ManagedTorrentLocked { - // The torrent might not be in "paused" state technically, - // but the intention might be for it to stay paused. - // - // This should change only on "unpause". - pub(crate) paused: bool, - pub(crate) state: ManagedTorrentState, - pub(crate) only_files: Option>, -} - -#[derive(Default)] -pub(crate) struct ManagedTorrentOptions { - pub force_tracker_interval: Option, - pub peer_connect_timeout: Option, - pub peer_read_write_timeout: Option, - pub allow_overwrite: bool, - pub output_folder: PathBuf, - pub ratelimits: LimitsConfig, - pub initial_peers: Vec, - pub peer_limit: Option, - #[cfg(feature = "disable-upload")] - pub _disable_upload: bool, -} - -impl ManagedTorrentOptions { - #[cfg(feature = "disable-upload")] - pub fn disable_upload(&self) -> bool { - self._disable_upload - } - - #[cfg(not(feature = "disable-upload"))] - pub const fn disable_upload(&self) -> bool { - false - } -} - -// Torrent bencodee "info" + some precomputed fields based on it for frequent access. -pub struct TorrentMetadata { - pub info: ValidatedTorrentMetaV1Info, - pub torrent_bytes: Bytes, - pub info_bytes: Bytes, - pub file_infos: FileInfos, -} - -impl TorrentMetadata { - pub(crate) fn new( - info: ValidatedTorrentMetaV1Info, - torrent_bytes: Bytes, - info_bytes: Bytes, - ) -> anyhow::Result { - let file_infos = info - .iter_file_details_ext() - .map(|fd| { - Ok::<_, anyhow::Error>(FileInfo { - relative_filename: fd.details.filename.to_pathbuf(), - offset_in_torrent: fd.offset, - piece_range: fd.pieces, - len: fd.details.len, - attrs: fd.details.attrs(), - }) - }) - .collect::>>()?; - - Ok(Self { - info, - torrent_bytes, - info_bytes, - file_infos, - }) - } - - pub fn lengths(&self) -> &Lengths { - self.info.lengths() - } -} - -/// Common information about torrent shared among all possible states. -/// -// The reason it's not inlined into ManagedTorrent is to break the Arc cycle: -// ManagedTorrent contains the current torrent state, which in turn needs access to a bunch -// of stuff, but it shouldn't access the state. -pub struct ManagedTorrentShared { - pub id: TorrentId, - pub info_hash: Id20, - pub(crate) spawner: BlockingSpawner, - pub trackers: HashSet, - pub peer_id: Id20, - pub span: tracing::Span, - pub(crate) options: ManagedTorrentOptions, - pub(crate) connector: Arc, - pub(crate) storage_factory: BoxStorageFactory, - pub(crate) session: Weak, - - // "dn" from magnet link - pub(crate) magnet_name: Option, - - /// BEP 19: WebSeed URLs parsed from the torrent's url-list field. - pub web_seed_urls: Vec, - - /// Category assigned to this torrent. - pub category: RwLock>, - - /// Per-torrent cancellation token, child of session token. - /// Used to cancel init tasks, checking, etc. when the torrent is deleted/forgotten. - /// Wrapped in a Mutex so it can be reset after a pause-during-init. - pub(crate) cancellation_token: parking_lot::Mutex, -} - -impl ManagedTorrentShared { - /// Cancel the per-torrent token and replace it with a fresh child of the session token. - /// Returns the old (now-cancelled) token. Used when pausing during initialization. - pub(crate) fn cancel_and_reset_token(&self) { - let mut guard = self.cancellation_token.lock(); - guard.cancel(); - if let Some(session) = self.session.upgrade() { - *guard = session.cancellation_token().child_token(); - } - } - - /// Cancel the per-torrent token permanently. Used when deleting/forgetting a torrent. - pub(crate) fn cancel_token(&self) { - self.cancellation_token.lock().cancel(); - } - - /// Create a child token from the per-torrent token for spawning tasks. - pub(crate) fn child_token(&self) -> CancellationToken { - self.cancellation_token.lock().child_token() - } -} - -pub struct ManagedTorrent { - // Static torrent configuration that doesn't change. - pub shared: Arc, - // Torrent metadata. Maybe be None when the magnet is resolving (not implemented yet) - pub metadata: ArcSwapOption, - pub(crate) state_change_notify: Notify, - pub(crate) locked: RwLock, -} - -impl ManagedTorrent { - pub fn id(&self) -> TorrentId { - self.shared.id - } - - pub fn name(&self) -> Option { - if let Some(m) = &*self.metadata.load() { - return m - .info - .name() - .map(|n| n.into_owned()) - .or_else(|| self.shared.magnet_name.clone()); - } - self.shared.magnet_name.clone() - } - - pub fn shared(&self) -> &ManagedTorrentShared { - &self.shared - } - - pub fn with_metadata( - &self, - mut f: impl FnMut(&Arc) -> R, - ) -> anyhow::Result { - let r = self.metadata.load(); - let r = r.as_ref().context("torrent is not resolved")?; - Ok(f(r)) - } - - pub fn info_hash(&self) -> Id20 { - self.shared.info_hash - } - - pub fn only_files(&self) -> Option> { - self.locked.read().only_files.clone() - } - - pub fn with_state(&self, f: impl FnOnce(&ManagedTorrentState) -> R) -> R { - f(&self.locked.read().state) - } - - pub(crate) fn with_state_mut(&self, f: impl FnOnce(&mut ManagedTorrentState) -> R) -> R { - f(&mut self.locked.write().state) - } - - pub(crate) fn with_chunk_tracker( - &self, - f: impl FnOnce(&ChunkTracker) -> R, - ) -> anyhow::Result { - let g = self.locked.read(); - match &g.state { - ManagedTorrentState::Paused(p) => Ok(f(&p.chunk_tracker)), - ManagedTorrentState::Live(l) => Ok(f(l - .lock_read("chunk_tracker") - .get_chunks() - .context("error getting chunks")?)), - _ => bail!("no chunk tracker, torrent neither paused nor live"), - } - } - - /// Get the live state if the torrent is live. - pub fn live(&self) -> Option> { - let g = self.locked.read(); - match &g.state { - ManagedTorrentState::Live(live) => Some(live.clone()), - _ => None, - } - } - - // Get live torrent but wait a bit until it's initialized if it is - pub(crate) async fn live_wait_initializing( - &self, - duration: Duration, - ) -> Option> { - timeout(duration, self.wait_until_initialized()) - .await - .ok()? - .ok()?; - self.live() - } - - fn stop_with_error(&self, error: anyhow::Error) { - let mut g = self.locked.write(); - - match g.state.take() { - ManagedTorrentState::Live(live) => { - if let Err(err) = live.pause() { - warn!( - id = self.shared.id, - info_hash = ?self.shared.info_hash, - "error pausing live torrent during fatal error handling: {err:#}", - ); - } - } - ManagedTorrentState::Error(e) => { - warn!( - id = self.shared.id, - info_hash = ?self.shared.info_hash, - "bug: torrent already was in error state when trying to stop it. Previous error was: {e:#}", - ); - } - ManagedTorrentState::None => { - warn!( - id = self.shared.id, - info_hash = ?self.shared.info_hash, - "bug: torrent encountered in None state during fatal error handling" - ) - } - _ => {} - }; - - self.state_change_notify.notify_waiters(); - - g.state = ManagedTorrentState::Error(error) - } - - /// peer_rx: the peer stream. If start_paused=false, must be set. - /// start_paused: if set, the torrent will initialize (check file integrity), but will not start - pub(crate) fn start( - self: &Arc, - peer_rx: Option, - start_paused: bool, - ) -> anyhow::Result<()> { - fn _start<'a>( - t: &'a Arc, - peer_rx: Option, - start_paused: bool, - session: Arc, - g: Option>, - token: CancellationToken, - ) -> anyhow::Result<()> { - let mut g = g.unwrap_or_else(|| t.locked.write()); - - match &g.state { - ManagedTorrentState::Live(_) => { - bail!("torrent is already live"); - } - ManagedTorrentState::Initializing(init) => { - let init = init.clone(); - let t = t.clone(); - let span = t.shared().span.clone(); - let token = token.clone(); - - spawn_with_cancel( - debug_span!(parent: span.clone(), "initialize_and_start"), - "initialize_and_start", - token.clone(), - async move { - let concurrent_init_semaphore = - session.concurrent_initialize_semaphore.clone(); - init.queued_for_init - .store(true, std::sync::atomic::Ordering::Relaxed); - let _permit = concurrent_init_semaphore - .acquire() - .await - .context("bug: concurrent init semaphore was closed")?; - init.queued_for_init - .store(false, std::sync::atomic::Ordering::Relaxed); - - match init.check().await { - Ok(paused) => { - let mut g = t.locked.write(); - if let ManagedTorrentState::Initializing(_) = &g.state { - } else { - debug!( - "no need to start torrent anymore, as it switched state from initializing" - ); - return Ok(()); - } - - g.state = ManagedTorrentState::Paused(paused); - t.state_change_notify.notify_waiters(); - _start(&t, peer_rx, start_paused, session, Some(g), token) - } - Err(err) => { - let result = anyhow::anyhow!("{:?}", err); - t.locked.write().state = ManagedTorrentState::Error(err); - t.state_change_notify.notify_waiters(); - Err(result) - } - } - }, - ); - Ok(()) - } - ManagedTorrentState::Paused(_) => { - if start_paused { - return Ok(()); - } - let paused = g.state.take().assert_paused(); - let (tx, rx) = tokio::sync::oneshot::channel(); - let live = TorrentStateLive::new(paused, tx, token.clone())?; - g.state = ManagedTorrentState::Live(live.clone()); - t.state_change_notify.notify_waiters(); - - spawn_fatal_errors_receiver(t, rx, token); - if let Some(peer_rx) = peer_rx { - spawn_peer_adder(&live, peer_rx); - } - Ok(()) - } - ManagedTorrentState::Error(_) => { - let metadata = t - .metadata - .load_full() - .context("torrent metadata was not loaded")?; - // Use false: fast resume validation already checks bitfield size, - // validates at least 1 piece per file, and probabilistically checks - // remaining pieces. Passing true would force a full disk recheck - // even for transient errors (network, tracker timeouts). - let initializing = Arc::new(TorrentStateInitializing::new( - t.shared.clone(), - metadata.clone(), - g.only_files.clone(), - t.shared - .storage_factory - .create_and_init(t.shared(), &metadata)?, - false, - )); - g.state = ManagedTorrentState::Initializing(initializing.clone()); - t.state_change_notify.notify_waiters(); - - // Recurse. - _start(t, peer_rx, start_paused, session, Some(g), token) - } - ManagedTorrentState::None => bail!("bug: torrent is in empty state"), - } - } - - let session = self - .shared - .session - .upgrade() - .context("session is dead, cannot start torrent")?; - let mut g = self.locked.write(); - g.paused = start_paused; - let cancellation_token = self.shared.child_token(); - - _start( - self, - peer_rx, - start_paused, - session, - Some(g), - cancellation_token, - ) - } - - pub fn is_paused(&self) -> bool { - self.locked.read().paused - } - - /// Pause the torrent if it's live or initializing. - pub(crate) fn pause(&self) -> anyhow::Result<()> { - let mut g = self.locked.write(); - match &g.state { - ManagedTorrentState::Live(live) => { - let paused = live.pause()?; - g.state = ManagedTorrentState::Paused(paused); - g.paused = true; - self.state_change_notify.notify_waiters(); - Ok(()) - } - ManagedTorrentState::Initializing(_) => { - // Cancel the init task via the per-torrent cancellation token, - // then reset the token so the torrent can be restarted later. - // Transition to Error state so it can be re-initialized on unpause. - self.shared.cancel_and_reset_token(); - g.state = - ManagedTorrentState::Error(anyhow::anyhow!("paused during initialization")); - g.paused = true; - self.state_change_notify.notify_waiters(); - Ok(()) - } - ManagedTorrentState::Paused(_) => { - bail!("torrent is already paused"); - } - ManagedTorrentState::Error(_) => { - bail!("can't pause torrent in error state") - } - ManagedTorrentState::None => bail!("bug: torrent is in empty state"), - } - } - - /// Get stats. - pub fn stats(&self) -> TorrentStats { - use stats::TorrentStatsState as S; - let mut resp = TorrentStats { - total_bytes: self - .metadata - .load() - .as_ref() - .map(|r| r.info.lengths().total_length()) - .unwrap_or_default(), - file_progress: Vec::new(), - state: S::Error, - error: None, - progress_bytes: 0, - uploaded_bytes: 0, - finished: false, - live: None, - queued_for_init: None, - }; - - self.with_state(|s| { - match s { - ManagedTorrentState::Initializing(i) => { - resp.state = S::Initializing; - resp.progress_bytes = i.checked_bytes.load(Ordering::Relaxed); - resp.queued_for_init = Some(i.is_queued_for_init()); - } - ManagedTorrentState::Paused(p) => { - resp.state = S::Paused; - let hns = p.hns(); - resp.total_bytes = hns.total(); - resp.progress_bytes = hns.progress(); - resp.finished = hns.finished(); - resp.file_progress = p.chunk_tracker.per_file_have_bytes().to_owned(); - } - ManagedTorrentState::Live(l) => { - resp.state = S::Live; - let live_stats = LiveStats::from(l.as_ref()); - let hns = l.get_hns().unwrap_or_default(); - resp.total_bytes = hns.total(); - resp.progress_bytes = hns.progress(); - resp.finished = hns.finished(); - resp.uploaded_bytes = l.get_uploaded_bytes(); - resp.file_progress = l - .lock_read("file_progress") - .get_chunks() - .ok() - .map(|c| c.per_file_have_bytes().to_owned()) - .unwrap_or_default(); - resp.live = Some(live_stats); - } - ManagedTorrentState::Error(e) => { - resp.state = S::Error; - resp.error = Some(format!("{e:?}")) - } - ManagedTorrentState::None => { - resp.state = S::Error; - resp.error = Some("bug: torrent in broken \"None\" state".to_string()); - } - } - resp - }) - } - - #[inline(never)] - pub fn wait_until_initialized(&self) -> BoxFuture<'_, anyhow::Result<()>> { - async move { - // TODO: rewrite, this polling is horrible - loop { - let done = self.with_state(|s| match s { - ManagedTorrentState::Initializing(_) => Ok(false), - ManagedTorrentState::Error(e) => bail!("{:?}", e), - ManagedTorrentState::None => bail!("bug: torrent state is None"), - _ => Ok(true), - })?; - if done { - return Ok(()); - } - let _ = timeout( - Duration::from_millis(100), - self.state_change_notify.notified(), - ) - .await; - } - } - .boxed() - } - - #[inline(never)] - pub fn wait_until_completed(&self) -> BoxFuture<'_, anyhow::Result<()>> { - async move { - // TODO: rewrite, this polling is horrible - let live = loop { - let live = self.with_state(|s| match s { - ManagedTorrentState::Initializing(_) | ManagedTorrentState::Paused(_) => { - Ok(None) - } - ManagedTorrentState::Live(l) => Ok(Some(l.clone())), - ManagedTorrentState::Error(e) => bail!("{:?}", e), - ManagedTorrentState::None => bail!("bug: torrent state is None"), - })?; - if let Some(live) = live { - break live; - } - let _ = timeout(Duration::from_secs(1), self.state_change_notify.notified()).await; - }; - - live.wait_until_completed().await; - Ok(()) - } - .boxed() - } - - // Returns true if needed to unpause torrent. - // This is just implementation detail - it's easier to pause/unpause than to tinker with internals. - pub(crate) fn update_only_files(&self, only_files: &HashSet) -> anyhow::Result<()> { - let metadata = self.metadata.load(); - let metadata = metadata.as_ref().context("torrent is not resolved")?; - let file_count = metadata.file_infos.len(); - for f in only_files.iter().copied() { - if f >= file_count { - anyhow::bail!("only_files contains invalid value {f}") - } - } - - // if live, need to update chunk tracker - // - if already finished: need to pause, then unpause (to reopen files etc) - // if paused, need to update chunk tracker - - let mut g = self.locked.write(); - match &mut g.state { - ManagedTorrentState::Initializing(_) => bail!("can't update initializing torrent"), - ManagedTorrentState::Error(_) => {} - ManagedTorrentState::None => {} - ManagedTorrentState::Paused(p) => { - p.update_only_files(only_files)?; - } - ManagedTorrentState::Live(l) => { - l.update_only_files(only_files)?; - } - }; - - g.only_files = Some(only_files.iter().copied().collect()); - Ok(()) - } -} - -pub type ManagedTorrentHandle = Arc; - -fn spawn_fatal_errors_receiver( - state: &Arc, - rx: tokio::sync::oneshot::Receiver, - token: CancellationToken, -) { - let span = state.shared.span.clone(); - let id = state.shared.id; - let info_hash = state.shared.info_hash; - let state = Arc::downgrade(state); - spawn_with_cancel::<&'static str>( - debug_span!(parent: span, "fatal_errors_receiver"), - "fatal_errors_receiver", - token, - async move { - let e = match rx.await { - Ok(e) => e, - Err(_) => return Ok(()), - }; - if let Some(state) = state.upgrade() { - state.stop_with_error(e); - } else { - warn!( - ?id, - ?info_hash, - "tried to stop the torrent with error, but couldn't upgrade the arc" - ); - } - Ok(()) - }, - ); -} - -fn spawn_peer_adder(live: &Arc, peer_rx: PeerStream) { - live.spawn( - debug_span!(parent: live.torrent().span.clone(), "external_peer_adder"), - format!("[{}]external_peer_adder", live.shared.id), - { - let live = live.clone(); - async move { - let live = { - let weak = Arc::downgrade(&live); - drop(live); - weak - }; - - drain_peer_stream(&live, peer_rx).await?; - - // The initial peer stream is exhausted. Now wait for rediscovery - // signals and create new peer streams on demand. - debug!("initial peer_rx exhausted, waiting for rediscovery signals"); - loop { - let state = match live.upgrade() { - Some(s) => s, - None => return Ok(()), - }; - - // Wait for rediscovery_notify to be signaled by the health monitor. - // We must hold the Arc alive while awaiting since Notified borrows Notify. - state.rediscovery_notify.notified().await; - - // Don't re-discover if we're finished. - if state.is_finished_and_no_active_streams() { - continue; - } - - debug!( - id = state.shared.id, - "rediscovery signal received, requesting new peers from DHT/trackers" - ); - - let session = match state.shared.session.upgrade() { - Some(s) => s, - None => return Ok(()), - }; - - // Create a fresh peer stream from DHT and trackers. - let is_private = state.metadata.info.info().private; - let new_peer_rx = session.make_peer_rx( - state.shared.info_hash, - state.shared.trackers.iter().cloned().collect(), - true, // announce - state.shared.options.force_tracker_interval, - Vec::new(), // no initial peers on re-discovery - is_private, - ); - drop(state); - - if let Some(rx) = new_peer_rx { - drain_peer_stream(&live, rx).await?; - debug!("rediscovery peer stream exhausted"); - } - } - } - }, - ); -} - -/// Drains a peer stream, adding each discovered peer to the torrent. -/// Returns when the stream ends or the torrent is no longer live. -async fn drain_peer_stream( - live: &std::sync::Weak, - mut peer_rx: PeerStream, -) -> crate::Result<()> { - loop { - match timeout(Duration::from_secs(5), peer_rx.next()).await { - Ok(Some(peer)) => { - trace!(?peer, "received peer"); - let state = match live.upgrade() { - Some(state) => state, - None => return Ok(()), - }; - state.add_peer_if_not_seen(peer)?; - } - Ok(None) => { - return Ok(()); - } - // If timeout, check if the torrent is live. - Err(_) if live.strong_count() == 0 => { - debug!("timed out waiting for peers, torrent isn't live"); - return Ok(()); - } - Err(_) => continue, - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/paused.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/paused.rs deleted file mode 100644 index 0bcda9f5..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/paused.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::{collections::HashSet, sync::Arc}; - -use crate::{ - chunk_tracker::{ChunkTracker, HaveNeededSelected}, - type_aliases::FileStorage, -}; - -use super::{ManagedTorrentShared, TorrentMetadata, streaming::TorrentStreams}; - -pub struct TorrentStatePaused { - pub(crate) shared: Arc, - pub(crate) metadata: Arc, - pub(crate) files: FileStorage, - pub(crate) chunk_tracker: ChunkTracker, - pub(crate) streams: Arc, -} - -impl TorrentStatePaused { - pub(crate) fn update_only_files(&mut self, only_files: &HashSet) -> anyhow::Result<()> { - self.chunk_tracker - .update_only_files(&self.metadata.file_infos, only_files)?; - Ok(()) - } - - pub(crate) fn hns(&self) -> &HaveNeededSelected { - self.chunk_tracker.get_hns() - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/stats.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/stats.rs deleted file mode 100644 index b1f37cf8..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/stats.rs +++ /dev/null @@ -1,238 +0,0 @@ -use std::time::Duration; - -use serde::Serialize; - -use super::{TorrentStateLive, live::stats::snapshot::StatsSnapshot}; -use size_format::SizeFormatterBinary as SF; - -#[derive(Serialize, Default, Debug)] -pub struct LiveStats { - pub snapshot: StatsSnapshot, - pub average_piece_download_time: Option, - pub download_speed: Speed, - pub upload_speed: Speed, - pub time_remaining: Option, -} - -impl std::fmt::Display for LiveStats { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "down speed: {}", self.download_speed)?; - if let Some(time_remaining) = &self.time_remaining { - write!(f, ", eta: {time_remaining}")?; - } - write!(f, ", up speed: {}", self.upload_speed)?; - Ok(()) - } -} - -impl From<&TorrentStateLive> for LiveStats { - fn from(live: &TorrentStateLive) -> Self { - let snapshot = live.stats_snapshot(); - let down_estimator = live.down_speed_estimator(); - let up_estimator = live.up_speed_estimator(); - - Self { - average_piece_download_time: snapshot.average_piece_download_time(), - snapshot, - download_speed: down_estimator.mbps().into(), - upload_speed: up_estimator.mbps().into(), - time_remaining: down_estimator - .time_remaining() - .map(DurationWithHumanReadable), - } - } -} - -#[derive(Clone, Copy, Serialize, Debug)] -pub enum TorrentStatsState { - #[serde(rename = "initializing")] - Initializing, - #[serde(rename = "live")] - Live, - #[serde(rename = "paused")] - Paused, - #[serde(rename = "error")] - Error, -} - -impl std::fmt::Display for TorrentStatsState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TorrentStatsState::Initializing => f.write_str("initializing"), - TorrentStatsState::Live => f.write_str("live"), - TorrentStatsState::Paused => f.write_str("paused"), - TorrentStatsState::Error => f.write_str("error"), - } - } -} - -#[derive(Serialize, Debug)] -pub struct TorrentStats { - pub state: TorrentStatsState, - pub file_progress: Vec, - pub error: Option, - pub progress_bytes: u64, - pub uploaded_bytes: u64, - pub total_bytes: u64, - pub finished: bool, - pub live: Option, - /// When in "initializing" state, true if the torrent is waiting for a - /// concurrent init slot rather than actively checking files. - #[serde(skip_serializing_if = "Option::is_none")] - pub queued_for_init: Option, -} - -impl std::fmt::Display for TorrentStats { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}: ", self.state)?; - if let Some(error) = &self.error { - return write!(f, "{error}"); - } - write!( - f, - "{} ({})", - self.progress_percent_human_readable(), - self.progress_bytes_human_readable() - )?; - if let Some(live) = &self.live { - write!(f, " [{live}]")?; - } - Ok(()) - } -} - -impl TorrentStats { - pub fn progress_percent_human_readable(&self) -> impl std::fmt::Display { - struct Percents { - progress: u64, - total: u64, - } - impl std::fmt::Display for Percents { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.total == 0 { - return write!(f, "N/A"); - } - let pct = self.progress as f64 / self.total as f64 * 100f64; - write!(f, "{pct:.2}%") - } - } - Percents { - progress: self.progress_bytes, - total: self.total_bytes, - } - } - - pub fn progress_bytes_human_readable(&self) -> impl std::fmt::Display { - struct Progress { - progress: u64, - total: u64, - } - impl std::fmt::Display for Progress { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} / {}", SF::new(self.progress), SF::new(self.total)) - } - } - Progress { - progress: self.progress_bytes, - total: self.total_bytes, - } - } -} - -fn format_seconds_to_time(seconds: u64, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let hours = seconds / 3600; - let minutes = (seconds % 3600) / 60; - let seconds = seconds % 60; - - if hours > 0 { - write!(f, "{hours}h {minutes}m") - } else if minutes > 0 { - write!(f, "{minutes}m {seconds}s") - } else { - write!(f, "{seconds}s") - } -} - -pub struct DurationWithHumanReadable(Duration); - -impl core::fmt::Display for DurationWithHumanReadable { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> core::fmt::Result { - format_seconds_to_time(self.0.as_secs(), f) - } -} - -impl core::fmt::Debug for DurationWithHumanReadable { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self}") - } -} - -impl Serialize for DurationWithHumanReadable { - fn serialize(&self, serializer: S) -> core::result::Result - where - S: serde::Serializer, - { - #[derive(Serialize)] - struct Tmp { - duration: Duration, - human_readable: String, - } - Tmp { - duration: self.0, - human_readable: self.to_string(), - } - .serialize(serializer) - } -} - -#[derive(Default)] -pub struct Speed { - pub mbps: f64, -} - -impl Speed { - fn new(mbps: f64) -> Self { - Self { mbps } - } - - #[allow(clippy::cast_possible_truncation)] - pub const fn as_bytes(&self) -> u64 { - (self.mbps * 1024f64 * 1024f64) as u64 - } -} - -impl From for Speed { - fn from(mbps: f64) -> Self { - Self::new(mbps) - } -} - -impl core::fmt::Display for Speed { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:.2} MiB/s", self.mbps) - } -} - -impl core::fmt::Debug for Speed { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self}") - } -} - -impl Serialize for Speed { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - #[derive(Serialize)] - struct Tmp { - mbps: f64, - human_readable: String, - } - Tmp { - mbps: self.mbps, - human_readable: self.to_string(), - } - .serialize(serializer) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/streaming.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/streaming.rs deleted file mode 100644 index 934e5d67..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/streaming.rs +++ /dev/null @@ -1,401 +0,0 @@ -use std::{ - collections::VecDeque, - io::SeekFrom, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - task::{Poll, Waker}, - time::Instant, -}; - -use anyhow::Context; -use dashmap::DashMap; - -use librtbit_core::lengths::{CurrentPiece, Lengths, ValidPieceIndex}; -use tokio::{ - io::{AsyncRead, AsyncSeek}, - sync::OwnedSemaphorePermit, -}; -use tracing::{debug, trace}; - -use crate::{ManagedTorrent, file_info::FileInfo, storage::TorrentStorage}; - -use super::{ManagedTorrentHandle, TorrentMetadata}; - -type StreamId = usize; - -// 32 mb lookahead by default. -const PER_STREAM_BUF_DEFAULT: u64 = 32 * 1024 * 1024; - -struct StreamState { - file_id: usize, - file_len: u64, - file_abs_offset: u64, - position: u64, - waker: Option, -} - -impl StreamState { - fn current_piece(&self, lengths: &Lengths) -> Option { - lengths.compute_current_piece(self.position, self.file_abs_offset) - } - - fn queue<'a>(&self, lengths: &'a Lengths) -> impl Iterator + use<'a> { - let start = self.file_abs_offset + self.position; - let end = (start + PER_STREAM_BUF_DEFAULT).min(self.file_abs_offset + self.file_len); - let dpl = lengths.default_piece_length(); - let start_id = (start / dpl as u64).try_into().unwrap(); - let end_id = end.div_ceil(dpl as u64).try_into().unwrap(); - (start_id..end_id).filter_map(|i| lengths.validate_piece_index(i)) - } -} - -#[derive(Default)] -pub(crate) struct TorrentStreams { - next_stream_id: AtomicUsize, - streams: DashMap, -} - -impl TorrentStreams { - fn next_id(&self) -> usize { - self.next_stream_id.fetch_add(1, Ordering::Relaxed) - } - - fn register_waker(&self, stream_id: StreamId, waker: Waker) { - if let Some(mut s) = self.streams.get_mut(&stream_id) { - let vm = s.value_mut(); - vm.waker = Some(waker); - } - } - - // Interleave 1st, 2nd etc pieces from each active stream in turn until they get 1/10th of the file . - pub(crate) fn iter_next_pieces<'a>( - &'a self, - lengths: &'a Lengths, - ) -> impl Iterator + 'a { - struct Interleave { - all: VecDeque, - } - - impl> Iterator for Interleave { - type Item = ValidPieceIndex; - - fn next(&mut self) -> Option { - while let Some(mut it) = self.all.pop_front() { - if let Some(piece) = it.next() { - self.all.push_back(it); - return Some(piece); - } - } - None - } - } - - let mut all: Vec<_> = self.streams.iter().map(|s| s.queue(lengths)).collect(); - - // Shuffle to decrease determinism and make queueing fairer. - use rand::seq::SliceRandom; - all.shuffle(&mut rand::rng()); - - Interleave { all: all.into() } - } - - pub(crate) fn wake_streams_on_piece_completed( - &self, - piece_id: ValidPieceIndex, - lengths: &Lengths, - ) { - for mut w in self.streams.iter_mut() { - if w.value().current_piece(lengths).map(|p| p.id) == Some(piece_id) - && let Some(waker) = w.value_mut().waker.take() - { - debug!( - stream_id = *w.key(), - piece_id = piece_id.get(), - "waking stream" - ); - waker.wake(); - } - } - } - - fn drop_stream(&self, stream_id: StreamId) -> Option { - debug!(stream_id, "dropping stream"); - self.streams.remove(&stream_id).map(|s| s.1) - } - - pub(crate) fn streamed_file_ids(&self) -> impl Iterator + '_ { - self.streams.iter().map(|s| s.value().file_id) - } -} - -pub struct FileStream { - torrent: ManagedTorrentHandle, - metadata: Arc, - streams: Arc, - stream_id: usize, - file_id: usize, - position: u64, - - // file params - file_len: u64, - file_torrent_abs_offset: u64, - - _blocking_permit: OwnedSemaphorePermit, -} - -macro_rules! map_io_err { - ($e:expr) => { - $e.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) - }; -} - -macro_rules! poll_try_io { - ($e:expr) => {{ - let e = map_io_err!($e); - match e { - Ok(r) => r, - Err(e) => { - debug!("stream error {e:#}"); - return Poll::Ready(Err(e)); - } - } - }}; -} - -impl AsyncRead for FileStream { - fn poll_read( - mut self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - tbuf: &mut tokio::io::ReadBuf<'_>, - ) -> Poll> { - // if the file is over, return 0 - if self.position == self.file_len { - debug!( - stream_id = self.stream_id, - file_id = self.file_id, - "stream completed, EOF" - ); - return Poll::Ready(Ok(())); - } - - let current = poll_try_io!( - self.metadata - .lengths() - .compute_current_piece(self.position, self.file_torrent_abs_offset) - .context("invalid position") - ); - - // if the piece is not there, register to wake when it is - // check if we have the piece for real - let have = poll_try_io!(self.torrent.with_chunk_tracker(|ct| { - let have = ct.get_have_pieces().as_slice()[current.id.get() as usize]; - if !have { - self.streams - .register_waker(self.stream_id, cx.waker().clone()); - } - have - })); - if !have { - debug!(stream_id = self.stream_id, file_id = self.file_id, piece_id = %current.id, "poll pending, not have"); - return Poll::Pending; - } - - // actually stream the piece - let buf = tbuf.initialize_unfilled(); - let file_remaining = self.file_len - self.position; - let bytes_to_read: usize = poll_try_io!( - (buf.len() as u64) - .min(current.piece_remaining as u64) - .min(file_remaining) - .try_into() - ); - - let buf = &mut buf[..bytes_to_read]; - - let start = Instant::now(); - poll_try_io!(poll_try_io!(self.torrent.shared.spawner.block_in_place( - || { - self.torrent.with_storage_and_file( - self.file_id, - |files, _fi| { - files.pread_exact(self.file_id, self.position, buf)?; - Ok::<_, anyhow::Error>(()) - }, - &self.metadata, - ) - } - ))); - - trace!( - buflen = buf.len(), - stream_id = self.stream_id, - file_id = self.file_id, - read_time = ?start.elapsed(), - "will write bytes" - ); - - self.as_mut().advance(bytes_to_read as u64); - tbuf.advance(bytes_to_read); - - Poll::Ready(Ok(())) - } -} - -impl AsyncSeek for FileStream { - fn start_seek( - mut self: std::pin::Pin<&mut Self>, - position: std::io::SeekFrom, - ) -> std::io::Result<()> { - let end_i64 = map_io_err!(TryInto::::try_into(self.file_len))?; - let new_pos: i64 = match position { - SeekFrom::Start(s) => map_io_err!(s.try_into())?, - SeekFrom::End(e) => map_io_err!(TryInto::::try_into(self.file_len))? + e, - SeekFrom::Current(o) => map_io_err!(TryInto::::try_into(self.position))? + o, - }; - - if new_pos < 0 || new_pos > end_i64 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - anyhow::anyhow!("invalid seek"), - )); - } - - self.as_mut().set_position(map_io_err!(new_pos.try_into())?); - debug!(stream_id = self.stream_id, position = self.position, "seek"); - Ok(()) - } - - fn poll_complete( - self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - ) -> Poll> { - Poll::Ready(Ok(self.position)) - } -} - -impl Drop for FileStream { - fn drop(&mut self) { - self.streams.drop_stream(self.stream_id); - } -} - -impl ManagedTorrent { - fn with_storage_and_file( - &self, - file_id: usize, - f: F, - metadata: &TorrentMetadata, - ) -> anyhow::Result - where - F: FnOnce(&dyn TorrentStorage, &FileInfo) -> R, - { - self.with_state(|s| { - let files = match s { - crate::ManagedTorrentState::Paused(p) => &*p.files, - crate::ManagedTorrentState::Live(l) => &*l.files, - s => anyhow::bail!("with_storage_and_file: invalid state: {}", s.name()), - }; - let fi = metadata.file_infos.get(file_id).context("invalid file")?; - Ok(f(files, fi)) - }) - } - - fn streams(&self) -> anyhow::Result> { - self.with_state(|s| match s { - crate::ManagedTorrentState::Paused(p) => Ok(p.streams.clone()), - crate::ManagedTorrentState::Live(l) => Ok(l.streams.clone()), - s => anyhow::bail!("streams: invalid state {}", s.name()), - }) - } - - fn maybe_reconnect_needed_peers_for_file(&self, file_id: usize) -> bool { - // If we have the full file, don't bother. - if self.is_file_finished(file_id) { - return false; - } - self.with_state(|state| { - if let crate::ManagedTorrentState::Live(l) = &state { - l.reconnect_all_not_needed_peers(); - } - }); - true - } - - fn is_file_finished(&self, file_id: usize) -> bool { - let metadata = self.metadata.load(); - let metadata = match metadata.as_ref() { - Some(r) => r, - None => return false, - }; - // TODO: would be nice to remove locking - self.with_chunk_tracker(|ct| ct.is_file_finished(&metadata.file_infos[file_id])) - .unwrap_or(false) - } - - pub async fn stream(self: Arc, file_id: usize) -> anyhow::Result { - let metadata = self - .metadata - .load_full() - .context("torrent metadata is not resolved")?; - let (fd_len, fd_offset) = self.with_storage_and_file( - file_id, - |_fd, fi| (fi.len, fi.offset_in_torrent), - &metadata, - )?; - let streams = self.streams()?; - let blocking_permit = self.shared().spawner.semaphore().acquire_owned().await?; - let s = FileStream { - stream_id: streams.next_id(), - streams: streams.clone(), - file_id, - position: 0, - - file_len: fd_len, - file_torrent_abs_offset: fd_offset, - _blocking_permit: blocking_permit, - torrent: self, - metadata, - }; - s.torrent.maybe_reconnect_needed_peers_for_file(file_id); - streams.streams.insert( - s.stream_id, - StreamState { - file_id, - position: 0, - waker: None, - file_len: fd_len, - file_abs_offset: fd_offset, - }, - ); - - debug!(stream_id = s.stream_id, file_id, "started stream"); - - Ok(s) - } -} - -impl FileStream { - pub fn position(&self) -> u64 { - self.position - } - - fn advance(&mut self, diff: u64) { - self.set_position(self.position + diff) - } - - fn set_position(&mut self, new_pos: u64) { - self.position = new_pos; - self.streams - .streams - .get_mut(&self.stream_id) - .unwrap() - .value_mut() - .position = new_pos; - } - - pub fn len(&self) -> u64 { - self.file_len - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/utils.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/utils.rs deleted file mode 100644 index 3acd71f0..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/torrent_state/utils.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::sync::atomic::{AtomicU32, Ordering}; - -pub fn atomic_inc(c: &AtomicU32) -> u32 { - c.fetch_add(1, Ordering::Relaxed) -} - -pub fn atomic_dec(c: &AtomicU32) -> u32 { - c.fetch_sub(1, Ordering::Relaxed) -} - -// Used during debugging to see if some locks take too long. -#[cfg(not(feature = "timed_existence"))] -mod timed_existence { - use std::ops::{Deref, DerefMut}; - - pub struct TimedExistence(T); - - impl TimedExistence { - #[inline(always)] - pub fn new(object: T, _reason: &'static str) -> Self { - Self(object) - } - } - - impl Deref for TimedExistence { - type Target = T; - - #[inline(always)] - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl DerefMut for TimedExistence { - #[inline(always)] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } - } - - #[inline(always)] - pub fn timeit(_n: impl std::fmt::Display, f: impl FnOnce() -> R) -> R { - f() - } -} - -#[cfg(feature = "timed_existence")] -mod timed_existence { - use std::ops::{Deref, DerefMut}; - use std::time::{Duration, Instant}; - use tracing::warn; - - const MAX: Duration = Duration::from_millis(50); - - // Prints if the object exists for too long. - // This is used to track long-lived locks for debugging. - pub struct TimedExistence { - object: T, - reason: &'static str, - started: Instant, - } - - impl TimedExistence { - pub fn new(object: T, reason: &'static str) -> Self { - Self { - object, - reason, - started: Instant::now(), - } - } - } - - impl Drop for TimedExistence { - fn drop(&mut self) { - let elapsed = self.started.elapsed(); - let reason = self.reason; - tracing::trace!(name=%self.reason, ?elapsed, "dropping guard"); - if elapsed > MAX { - warn!("elapsed on lock {reason:?}: {elapsed:?}") - } - } - } - - impl Deref for TimedExistence { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.object - } - } - - impl DerefMut for TimedExistence { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.object - } - } - - pub fn timeit(name: impl std::fmt::Display, f: impl FnOnce() -> R) -> R { - let now = Instant::now(); - tracing::trace!(%name, "starting"); - let r = f(); - tracing::trace!(%name, "done"); - let elapsed = now.elapsed(); - if elapsed > MAX { - warn!(%name, ?elapsed, max = ?MAX, "elapsed > MAX"); - } - r - } -} - -pub use timed_existence::{TimedExistence, timeit}; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tracing_subscriber_config_utils.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tracing_subscriber_config_utils.rs deleted file mode 100644 index e85bcc70..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/tracing_subscriber_config_utils.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::io::LineWriter; - -use anyhow::Context; -use bytes::Bytes; -use librtbit_core::spawn_utils::spawn; -use tracing::debug_span; -use tracing_subscriber::{filter::FilterExt, fmt::MakeWriter}; - -struct Subscriber { - tx: tokio::sync::broadcast::Sender, -} - -struct Writer { - tx: tokio::sync::broadcast::Sender, -} - -pub type LineBroadcast = tokio::sync::broadcast::Sender; - -impl Subscriber { - pub fn new() -> (Self, LineBroadcast) { - let (tx, _) = tokio::sync::broadcast::channel(100); - (Self { tx: tx.clone() }, tx) - } -} - -impl MakeWriter<'_> for Subscriber { - type Writer = LineWriter; - - fn make_writer(&self) -> Self::Writer { - LineWriter::new(Writer { - tx: self.tx.clone(), - }) - } -} - -impl std::io::Write for Writer { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let len = buf.len(); - if self.tx.receiver_count() == 0 { - return Ok(len); - } - let arc = buf.to_vec().into(); - let _ = self.tx.send(arc); - Ok(len) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -pub struct InitLoggingOptions<'a> { - pub default_rust_log_value: Option<&'a str>, - pub log_file: Option<&'a str>, - pub log_file_rust_log: Option<&'a str>, -} - -pub struct InitLoggingResult { - pub rust_log_reload_tx: tokio::sync::mpsc::UnboundedSender, - pub line_broadcast: LineBroadcast, -} - -#[inline(never)] -pub fn init_logging(opts: InitLoggingOptions) -> anyhow::Result { - let stderr_filter = EnvFilter::builder() - .with_default_directive( - opts.default_rust_log_value - .unwrap_or("info") - .parse() - .context("can't parse provided rust_log value")?, - ) - .from_env() - .context("invalid RUST_LOG value")?; - - let (stderr_filter, reload_stderr_filter) = - tracing_subscriber::reload::Layer::new(stderr_filter); - - use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - - let (line_sub, line_broadcast) = Subscriber::new(); - - let layered = tracing_subscriber::registry() - // Stderr logging layer. - .with(fmt::layer().with_filter(stderr_filter)) - // HTTP API log broadcast layer. - .with( - fmt::layer() - .with_ansi(false) - .fmt_fields(tracing_subscriber::fmt::format::JsonFields::new()) - .event_format(fmt::format().with_ansi(false).json()) - .with_writer(line_sub) - .with_filter( - tracing_subscriber::filter::filter_fn({ - let line_broadcast = line_broadcast.clone(); - move |_| line_broadcast.receiver_count() > 0 - }) - .and(EnvFilter::builder().parse("info,librtbit=debug").unwrap()), - ), - ); - - #[cfg(feature = "tokio-console")] - let console_layer = console_subscriber::spawn(); - - #[cfg(feature = "tokio-console")] - let layered = layered.with(console_layer); - - if let Some(log_file) = &opts.log_file { - let log_file = log_file.to_string(); - let log_file = std::sync::Mutex::new(LineWriter::new( - std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_file) - .with_context(|| format!("error opening log file {log_file:?}"))?, - )); - layered - .with( - fmt::layer() - .with_ansi(false) - .with_writer(log_file) - .with_filter( - EnvFilter::builder() - .parse(opts.log_file_rust_log.unwrap_or("info,librtbit=debug")) - .context("can't parse log-file-rust-log")?, - ), - ) - .try_init() - .context("can't init logging")?; - } else { - layered.try_init().context("can't init logging")?; - } - - let (reload_tx, mut reload_rx) = tokio::sync::mpsc::unbounded_channel::(); - spawn::<&'static str>( - debug_span!("fmt_filter_reloader"), - "fmt_filter_reloader", - async move { - while let Some(rust_log) = reload_rx.recv().await { - let stderr_env_filter = match EnvFilter::builder().parse(&rust_log) { - Ok(f) => f, - Err(e) => { - eprintln!("can't parse env filter {rust_log:?}: {e:#?}"); - continue; - } - }; - eprintln!("setting RUST_LOG to {rust_log:?}"); - let _ = reload_stderr_filter.reload(stderr_env_filter); - } - Ok(()) - }, - ); - Ok(InitLoggingResult { - rust_log_reload_tx: reload_tx, - line_broadcast, - }) -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/type_aliases.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/type_aliases.rs deleted file mode 100644 index 2c6b8a2a..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/type_aliases.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::net::SocketAddr; - -use futures::stream::BoxStream; -use tokio::io::AsyncWrite; - -use crate::{file_info::FileInfo, storage::TorrentStorage, vectored_traits::AsyncReadVectored}; - -// NOTE: Msb0 is used because that's what bittorrent protocol uses for bitfield. -// Don't change to Lsb0 even though it might be a bit faster (in theory) on LE architectures. -pub type BS = bitvec::slice::BitSlice; -pub type BF = bitvec::boxed::BitBox; - -pub type PeerHandle = SocketAddr; -pub type PeerStream = BoxStream<'static, SocketAddr>; -pub type FileInfos = Vec; -pub(crate) type FileStorage = Box; -pub(crate) type FilePriorities = Vec; - -pub(crate) type BoxAsyncReadVectored = Box; -pub(crate) type BoxAsyncWrite = Box; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/upnp_server_adapter.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/upnp_server_adapter.rs deleted file mode 100644 index 072f8397..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/upnp_server_adapter.rs +++ /dev/null @@ -1,698 +0,0 @@ -use std::{ - collections::{ - HashMap, - hash_map::Entry::{Occupied, Vacant}, - }, - sync::Arc, -}; - -use crate::{ManagedTorrentShared, Session, session::TorrentId, torrent_state::TorrentMetadata}; - -#[derive(Clone)] -pub struct UpnpServerSessionAdapter { - session: Arc, -} - -use anyhow::Context; -use buffers::ByteBufOwned; -use itertools::Itertools; -use librtbit_core::torrent_metainfo::ValidatedTorrentMetaV1Info; -use tracing::{debug, trace, warn}; -use upnp_serve::{ - UpnpServer, UpnpServerOptions, - services::content_directory::{ - ContentDirectoryBrowseProvider, - browse::response::{Container, Item, ItemOrContainer}, - }, -}; - -#[derive(Debug, PartialEq, Eq)] -struct TorrentFileTreeNode { - title: String, - // must be set for all nodes except the root node. - parent_id: Option, - children: Vec, - - real_torrent_file_id: Option, -} - -fn encode_id(local_id: usize, torrent_id: usize) -> usize { - (local_id << 16) | (torrent_id + 1) -} - -fn decode_id(id: usize) -> anyhow::Result<(usize, usize)> { - let torrent_id = id & 0xffff; - if torrent_id == 0 { - anyhow::bail!("invalid id") - } - let torrent_id = torrent_id - 1; - Ok((id >> 16, torrent_id)) -} - -impl TorrentFileTreeNode { - fn as_item_or_container( - &self, - id: usize, - http_host: &str, - torrent: &ManagedTorrentShared, - metadata: &TorrentMetadata, - ) -> ItemOrContainer { - let encoded_id = encode_id(id, torrent.id); - let encoded_parent_id = self.parent_id.map(|p| encode_id(p, torrent.id)); - match self.real_torrent_file_id { - Some(fid) => { - let fi = &metadata.file_infos[fid]; - let filename = &fi.relative_filename; - // Torrent path joined with "/" - let last_url_bit = metadata - .info - .iter_file_details() - .nth(fid) - .map(|fd| fd.filename.to_vec()) - .map(|components| { - components - .into_iter() - .map(|c| urlencoding::encode(&c).into_owned()) - .join("/") - }) - .unwrap_or_else(|| self.title.clone()); - ItemOrContainer::Item(Item { - id: encoded_id, - parent_id: encoded_parent_id.unwrap_or_default(), - title: self.title.clone(), - mime_type: mime_guess::from_path(filename).first(), - url: format!( - "http://{}/torrents/{}/stream/{}/{}", - http_host, torrent.id, fid, last_url_bit - ), - size: fi.len, - }) - } - None => ItemOrContainer::Container(Container { - id: encoded_id, - parent_id: Some(encoded_parent_id.unwrap_or_default()), - title: self.title.clone(), - children_count: Some(self.children.len()), - }), - } - } -} - -struct TorrentFileTree { - // root id is 0 - nodes: Vec, -} - -fn is_single_file_at_root(info: &ValidatedTorrentMetaV1Info) -> bool { - info.iter_file_details() - .flat_map(move |fd| fd.filename.iter_components()) - .nth(1) - .is_none() -} - -impl TorrentFileTree { - fn build( - torent_id: TorrentId, - info: &ValidatedTorrentMetaV1Info, - ) -> anyhow::Result { - if is_single_file_at_root(info) { - let filename = info - .iter_file_details() - .next() - .unwrap() - .filename - .iter_components() - .last() - .unwrap(); - let root_node = TorrentFileTreeNode { - title: filename.into_owned(), - parent_id: None, - children: vec![], - real_torrent_file_id: Some(0), - }; - return Ok(TorrentFileTree { - nodes: vec![root_node], - }); - } - - let root_node = TorrentFileTreeNode { - title: info - .name_or_else(|| format!("torrent {torent_id}")) - .into_owned(), - parent_id: None, - children: vec![], - real_torrent_file_id: None, - }; - - let mut tree = TorrentFileTree { - nodes: vec![root_node], - }; - - let mut name_cache = HashMap::new(); - - for (fid, fd) in info.iter_file_details().enumerate() { - let components = fd.filename.to_vec(); - let mut parent_id = 0; - let mut it = components.iter().peekable(); - while let Some(component) = it.next() { - let is_last = it.peek().is_none(); - if is_last { - let current_id = tree.nodes.len(); - let node = TorrentFileTreeNode { - title: component.clone(), - parent_id: Some(parent_id), - children: vec![], - real_torrent_file_id: Some(fid), - }; - tree.nodes.push(node); - tree.nodes[parent_id].children.push(current_id); - break; - } - - parent_id = match name_cache.entry((parent_id, component.clone())) { - Occupied(occ) => *occ.get(), - Vacant(vac) => { - let id = tree.nodes.len(); - let node = TorrentFileTreeNode { - title: component.clone(), - parent_id: Some(parent_id), - children: vec![], - real_torrent_file_id: None, - }; - tree.nodes.push(node); - tree.nodes[parent_id].children.push(id); - vac.insert(id); - id - } - }; - } - } - - Ok(tree) - } -} - -impl UpnpServerSessionAdapter { - fn build_root(&self, hostname: &str) -> Vec { - let mut all = self - .session - .with_torrents(|torrents| torrents.map(|(_, t)| t.clone()).collect_vec()); - - all.sort_unstable_by_key(|t| t.id()); - - all.iter() - .filter_map(|t| { - let real_id = t.id(); - let upnp_id = real_id + 1; - let metadata = t.metadata.load(); - let metadata = match metadata.as_ref() { - Some(r) => r, - None => return None, - }; - - if is_single_file_at_root(&metadata.info) { - // Just add the file directly - let rf = &metadata.file_infos[0].relative_filename; - let title = rf.file_name()?.to_str()?.to_owned(); - Some( - TorrentFileTreeNode { - title, - parent_id: None, - children: vec![], - real_torrent_file_id: Some(0), - } - .as_item_or_container( - 0, - hostname, - t.shared(), - metadata, - ), - ) - } else { - let title = metadata - .info - .name_or_else(|| format!("torrent {real_id}")) - .into_owned(); - - // Create a folder - Some(ItemOrContainer::Container(Container { - id: upnp_id, - parent_id: Some(0), - title, - children_count: None, - })) - } - }) - .collect_vec() - } - - fn build_impl( - &self, - object_id: usize, - http_hostname: &str, - metadata: bool, - ) -> Vec { - if object_id == 0 { - let root = self.build_root(http_hostname); - if metadata { - return vec![ItemOrContainer::Container(Container { - id: 0, - parent_id: None, - children_count: Some(root.len()), - title: "root".to_owned(), - })]; - } - return root; - } - - let (node_id, torrent_id) = match decode_id(object_id) { - Ok((node_id, torrent_id)) => (node_id, torrent_id), - Err(_) => { - debug!(id=?object_id, "invalid id"); - return vec![]; - } - }; - trace!(object_id, node_id, torrent_id); - - let torrent = match self.session.get(torrent_id.into()) { - Some(t) => t, - None => { - warn!(torrent_id, "no such torrent"); - return vec![]; - } - }; - - let t_metadata = torrent.metadata.load(); - let t_metadata = match t_metadata.as_ref() { - Some(r) => r, - None => return vec![], - }; - - let tree = match TorrentFileTree::build(torrent.id(), &t_metadata.info) { - Ok(tree) => tree, - Err(e) => { - warn!(object_id, error=?e, "error building torrent file tree"); - return vec![]; - } - }; - - let node = match tree.nodes.get(node_id) { - Some(n) => n, - None => { - warn!(torrent_id, node_id, "no such internal ID in torrent"); - return vec![]; - } - }; - - trace!(node_id, torrent_id, ?node); - - let mut result = Vec::new(); - - if node.real_torrent_file_id.is_some() || metadata { - result.push(node.as_item_or_container( - node_id, - http_hostname, - torrent.shared(), - t_metadata, - )) - } else { - for (child_node_id, child_node) in node - .children - .iter() - .filter_map(|id| Some((*id, tree.nodes.get(*id)?))) - { - result.push(child_node.as_item_or_container( - child_node_id, - http_hostname, - torrent.shared(), - t_metadata, - )); - } - }; - - result - } -} - -impl ContentDirectoryBrowseProvider for UpnpServerSessionAdapter { - fn browse_direct_children( - &self, - object_id: usize, - http_hostname: &str, - ) -> Vec { - self.build_impl(object_id, http_hostname, false) - } - - fn browse_metadata(&self, object_id: usize, http_hostname: &str) -> Vec { - self.build_impl(object_id, http_hostname, true) - } -} - -impl Session { - pub async fn make_upnp_adapter( - self: &Arc, - friendly_name: String, - http_listen_port: u16, - ) -> anyhow::Result { - UpnpServer::new(UpnpServerOptions { - friendly_name, - http_listen_port, - http_prefix: "/upnp".to_owned(), - browse_provider: Box::new(UpnpServerSessionAdapter { - session: self.clone(), - }), - cancellation_token: self.cancellation_token().child_token(), - }) - .await - .context("error creating upnp adapter") - } -} - -#[cfg(test)] -mod tests { - use bencode::bencode_serialize_to_writer; - use bytes::Bytes; - use dht::Id20; - use librtbit_core::torrent_metainfo::{ - TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned, - }; - use tempfile::TempDir; - use upnp_serve::services::content_directory::{ - ContentDirectoryBrowseProvider, - browse::response::{Container, Item, ItemOrContainer}, - }; - - use crate::{ - AddTorrent, AddTorrentOptions, Session, SessionOptions, - tests::test_util::setup_test_logging, - upnp_server_adapter::{ - TorrentFileTree, TorrentFileTreeNode, UpnpServerSessionAdapter, decode_id, encode_id, - }, - }; - - fn create_torrent(name: Option<&str>, files: &[&str]) -> TorrentMetaV1Owned { - // Each file is 1 byte with piece_length=1, so we need one 20-byte SHA-1 hash per file. - let dummy_pieces: Vec = vec![0u8; files.len() * 20]; - TorrentMetaV1Owned { - announce: None, - announce_list: vec![], - info: bencode::WithRawBytes { - data: TorrentMetaV1Info { - name: name.map(|n| n.as_bytes().into()), - pieces: Some(dummy_pieces.into()), - piece_length: 1, - meta_version: None, - length: None, - md5sum: None, - files: Some( - files - .iter() - .map(|f| TorrentMetaV1File { - length: 1, - path: f.split("/").map(|f| f.as_bytes().into()).collect(), - attr: None, - sha1: None, - symlink_path: None, - }) - .collect(), - ), - attr: None, - sha1: None, - symlink_path: None, - private: false, - }, - raw_bytes: Default::default(), - }, - comment: None, - created_by: None, - encoding: None, - publisher: None, - publisher_url: None, - creation_date: None, - url_list: None, - info_hash: Id20::default(), - } - } - - #[test] - fn test_torrent_file_tree_single() -> anyhow::Result<()> { - let t = create_torrent(Some("test t"), &["file0"]); - let tree = TorrentFileTree::build(0, &t.info.data.validate().unwrap())?; - assert_eq!( - &tree.nodes, - &[TorrentFileTreeNode { - children: vec![], - parent_id: None, - real_torrent_file_id: Some(0), - title: "file0".into() - }] - ); - - Ok(()) - } - - #[test] - fn test_torrent_file_tree_flat() -> anyhow::Result<()> { - let t = create_torrent(Some("test t"), &["file0", "file1"]); - let tree = TorrentFileTree::build(0, &t.info.data.validate().unwrap())?; - assert_eq!( - &tree.nodes, - &[ - TorrentFileTreeNode { - children: vec![1, 2], - parent_id: None, - real_torrent_file_id: None, - title: "test t".into() - }, - TorrentFileTreeNode { - children: vec![], - parent_id: Some(0), - real_torrent_file_id: Some(0), - title: "file0".into() - }, - TorrentFileTreeNode { - children: vec![], - parent_id: Some(0), - real_torrent_file_id: Some(1), - title: "file1".into() - } - ] - ); - - Ok(()) - } - - #[test] - fn test_torrent_file_tree_nested() -> anyhow::Result<()> { - let t = create_torrent( - Some("test t"), - &["file0", "file1", "dir0/file2", "dir0/dir1/file3"], - ); - let tree = TorrentFileTree::build(0, &t.info.data.validate().unwrap())?; - assert_eq!( - &tree.nodes, - &[ - TorrentFileTreeNode { - children: vec![1, 2, 3], - parent_id: None, - real_torrent_file_id: None, - title: "test t".into() - }, - TorrentFileTreeNode { - children: vec![], - parent_id: Some(0), - real_torrent_file_id: Some(0), - title: "file0".into() - }, - TorrentFileTreeNode { - children: vec![], - parent_id: Some(0), - real_torrent_file_id: Some(1), - title: "file1".into() - }, - TorrentFileTreeNode { - children: vec![4, 5], - parent_id: Some(0), - real_torrent_file_id: None, - title: "dir0".into() - }, - TorrentFileTreeNode { - children: vec![], - parent_id: Some(3), - real_torrent_file_id: Some(2), - title: "file2".into() - }, - TorrentFileTreeNode { - children: vec![6], - parent_id: Some(3), - real_torrent_file_id: None, - title: "dir1".into() - }, - TorrentFileTreeNode { - children: vec![], - parent_id: Some(5), - real_torrent_file_id: Some(3), - title: "file3".into() - }, - ] - ); - - Ok(()) - } - - #[tokio::test] - async fn test_browse() { - setup_test_logging(); - - let t1 = create_torrent(Some("t1"), &["f1"]); - let t2 = create_torrent(Some("t2"), &["d1/f2"]); - - fn as_bytes(t: &TorrentMetaV1Owned) -> Bytes { - let mut b = Vec::new(); - bencode_serialize_to_writer(t, &mut b).unwrap(); - b.into() - } - - let td = TempDir::new().unwrap(); - let session = Session::new_with_opts( - td.path().to_owned(), - SessionOptions { - disable_dht: true, - ..Default::default() - }, - ) - .await - .unwrap(); - - session - .add_torrent( - AddTorrent::from_bytes(as_bytes(&t1)), - Some(AddTorrentOptions { - paused: true, - ..Default::default() - }), - ) - .await - .unwrap(); - session - .add_torrent( - AddTorrent::from_bytes(as_bytes(&t2)), - Some(AddTorrentOptions { - paused: true, - ..Default::default() - }), - ) - .await - .unwrap(); - - let adapter = UpnpServerSessionAdapter { session }; - - assert_eq!( - adapter.browse_metadata(0, "127.0.0.1"), - vec![ItemOrContainer::Container(Container { - id: 0, - parent_id: None, - children_count: Some(2), - title: "root".into() - })] - ); - - assert_eq!( - adapter.browse_direct_children(0, "127.0.0.1"), - vec![ - ItemOrContainer::Item(Item { - id: encode_id(0, 0), - parent_id: 0, - title: "f1".into(), - mime_type: None, - url: "http://127.0.0.1/torrents/0/stream/0/f1".into(), - size: 1, - }), - ItemOrContainer::Container(Container { - id: encode_id(0, 1), - parent_id: Some(0), - children_count: None, - title: "t2".into() - }) - ] - ); - - assert_eq!( - adapter.browse_metadata(encode_id(0, 0), "127.0.0.1"), - vec![ItemOrContainer::Item(Item { - id: encode_id(0, 0), - parent_id: 0, - title: "f1".into(), - mime_type: None, - url: "http://127.0.0.1/torrents/0/stream/0/f1".into(), - size: 1, - })] - ); - - assert_eq!( - adapter.browse_metadata(encode_id(0, 1), "127.0.0.1"), - vec![ItemOrContainer::Container(Container { - id: encode_id(0, 1), - parent_id: Some(0), - children_count: Some(1), - title: "t2".into() - })] - ); - - assert_eq!( - adapter.browse_direct_children(encode_id(0, 1), "127.0.0.1"), - vec![ItemOrContainer::Container(Container { - id: encode_id(1, 1), - parent_id: Some(encode_id(0, 1)), - children_count: Some(1), - title: "d1".into() - }),] - ); - - assert_eq!( - adapter.browse_metadata(encode_id(1, 1), "127.0.0.1"), - vec![ItemOrContainer::Container(Container { - id: encode_id(1, 1), - parent_id: Some(encode_id(0, 1)), - children_count: Some(1), - title: "d1".into() - }),] - ); - - assert_eq!( - adapter.browse_direct_children(encode_id(1, 1), "127.0.0.1"), - vec![ItemOrContainer::Item(Item { - id: encode_id(2, 1), - parent_id: encode_id(1, 1), - title: "f2".into(), - mime_type: None, - url: "http://127.0.0.1/torrents/1/stream/0/d1/f2".into(), - size: 1, - })] - ); - - assert_eq!( - adapter.browse_metadata(encode_id(2, 1), "127.0.0.1"), - vec![ItemOrContainer::Item(Item { - id: encode_id(2, 1), - parent_id: encode_id(1, 1), - title: "f2".into(), - mime_type: None, - url: "http://127.0.0.1/torrents/1/stream/0/d1/f2".into(), - size: 1, - })] - ); - } - - #[test] - fn test_encode_id() { - for local_id in 0..5 { - for torrent_id in 0..5 { - let encoded = encode_id(local_id, torrent_id); - let (decoded_local_id, decoded_torrent_id) = decode_id(encoded).unwrap(); - assert_eq!(local_id, decoded_local_id); - assert_eq!(torrent_id, decoded_torrent_id); - } - } - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/vectored_traits.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/vectored_traits.rs deleted file mode 100644 index d5664a1b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/vectored_traits.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::{future::poll_fn, io::IoSliceMut, pin::Pin, task::Poll}; - -use tokio::io::AsyncRead; - -pub trait AsyncReadVectored: AsyncRead + Unpin { - fn poll_read_vectored( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - vec: &mut [IoSliceMut<'_>], - ) -> Poll>; -} - -impl AsyncReadVectored for &mut T { - fn poll_read_vectored( - mut self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - vec: &mut [IoSliceMut<'_>], - ) -> Poll> { - Pin::new(&mut **self).poll_read_vectored(cx, vec) - } -} - -impl AsyncReadVectored for Box { - fn poll_read_vectored( - mut self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - vec: &mut [IoSliceMut<'_>], - ) -> Poll> { - Pin::new(&mut **self).poll_read_vectored(cx, vec) - } -} - -pub trait AsyncReadVectoredExt { - async fn read_vectored(&mut self, vec: &mut [IoSliceMut<'_>]) -> std::io::Result; -} - -impl AsyncReadVectoredExt for T { - async fn read_vectored(&mut self, vec: &mut [IoSliceMut<'_>]) -> std::io::Result { - poll_fn(|cx| Pin::new(&mut *self).poll_read_vectored(cx, vec)).await - } -} - -impl AsyncReadVectored for tokio::net::tcp::OwnedReadHalf { - fn poll_read_vectored( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - vec: &mut [IoSliceMut<'_>], - ) -> Poll> { - let this = self.get_mut(); - loop { - match this.try_read_vectored(vec) { - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::task::ready!(this.as_ref().poll_read_ready(cx)?); - continue; - } - res => return Poll::Ready(res), - } - } - } -} - -impl AsyncReadVectored for librqbit_utp::UtpStreamReadHalf { - fn poll_read_vectored( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - vec: &mut [IoSliceMut<'_>], - ) -> Poll> { - librqbit_utp::UtpStreamReadHalf::poll_read_vectored(self, cx, vec) - } -} - -pub struct AsyncReadToVectoredCompat(T); - -impl AsyncRead for AsyncReadToVectoredCompat { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &mut tokio::io::ReadBuf<'_>, - ) -> Poll> { - Pin::new(&mut self.get_mut().0).poll_read(cx, buf) - } -} - -impl AsyncReadVectored for AsyncReadToVectoredCompat { - fn poll_read_vectored( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - vec: &mut [IoSliceMut<'_>], - ) -> Poll> { - let first_non_empty = match vec.iter_mut().find(|s| !s.is_empty()) { - Some(s) => &mut **s, - None => return Poll::Ready(Ok(0)), - }; - let mut rbuf = tokio::io::ReadBuf::new(first_non_empty); - std::task::ready!(self.poll_read(cx, &mut rbuf)?); - Poll::Ready(Ok(rbuf.filled().len())) - } -} - -pub trait AsyncReadVectoredIntoCompat: Sized { - fn into_vectored_compat(self) -> AsyncReadToVectoredCompat; -} - -impl AsyncReadVectoredIntoCompat for T { - fn into_vectored_compat(self) -> AsyncReadToVectoredCompat { - AsyncReadToVectoredCompat(self) - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/watch.rs b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/watch.rs deleted file mode 100644 index 42babd0f..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/src/watch.rs +++ /dev/null @@ -1,226 +0,0 @@ -use std::{ - io::Read, - path::{Path, PathBuf}, - sync::{Arc, Weak}, -}; - -use crate::Magnet; -use anyhow::{Context, bail}; -use librtbit_core::{spawn_utils::spawn, torrent_metainfo::torrent_from_bytes}; -use notify::Watcher; -use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; -use tracing::{debug, debug_span, error, trace, warn}; -use walkdir::WalkDir; - -use crate::{AddTorrent, AddTorrentOptions, AddTorrentResponse, Session}; - -struct ThreadCancelEvent { - mutex: parking_lot::Mutex, - condvar: parking_lot::Condvar, -} - -impl ThreadCancelEvent { - fn new() -> Arc { - Arc::new(Self { - mutex: parking_lot::Mutex::new(false), - condvar: parking_lot::Condvar::new(), - }) - } - - fn cancel(&self) { - let mut g = self.mutex.lock(); - *g = true; - self.condvar.notify_all(); - } - - fn wait_until_cancelled(&self) { - let mut g = self.mutex.lock(); - while !*g { - self.condvar.wait(&mut g); - } - } -} - -async fn watch_adder( - session_w: Weak, - mut rx: UnboundedReceiver<(AddTorrent<'static>, PathBuf)>, -) { - async fn add_one( - session_w: &Weak, - add_torrent: AddTorrent<'static>, - path: PathBuf, - ) -> anyhow::Result<()> { - let session = match session_w.upgrade() { - Some(s) => s, - None => return Ok(()), - }; - let res = session - .add_torrent( - add_torrent, - Some(AddTorrentOptions { - overwrite: true, - ..Default::default() - }), - ) - .await - .with_context(|| format!("error adding torrent from {path:?}"))?; - match res { - AddTorrentResponse::Added(_, _) => {} - AddTorrentResponse::AlreadyManaged(_, _) => { - debug!(?path, "already managed"); - } - AddTorrentResponse::ListOnly(..) => bail!("bug: unexpected list only"), - } - Ok(()) - } - - while let Some((add_torrent, path)) = rx.recv().await { - if let Err(e) = add_one(&session_w, add_torrent, path).await { - warn!("error adding torrent: {e:#}"); - } - } -} - -fn watch_thread( - folder: PathBuf, - tx: UnboundedSender<(AddTorrent<'static>, PathBuf)>, - cancel_event: &ThreadCancelEvent, -) -> anyhow::Result<()> { - fn read_and_validate_magnet(path: &Path) -> anyhow::Result> { - let mut url = String::new(); - std::fs::File::open(path) - .context("error opening")? - .read_to_string(&mut url) - .context("error reading")?; - debug!("validating {url}"); - Magnet::parse(&url)?; - Ok(AddTorrent::Url(url.into())) - } - - fn read_and_validate_torrent(path: &Path) -> anyhow::Result> { - let mut buf = Vec::new(); - std::fs::File::open(path) - .context("error opening")? - .read_to_end(&mut buf) - .context("error reading")?; - torrent_from_bytes(&buf).context("invalid .torrent file")?; - Ok(AddTorrent::from_bytes(buf)) - } - - fn watch_cb( - ev: notify::Result, - tx: &UnboundedSender<(AddTorrent<'static>, PathBuf)>, - ) -> anyhow::Result<()> { - trace!(event=?ev, "watch event"); - let ev = ev.context("error event")?; - match ev.kind { - notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {} - other => { - debug!(kind=?other, paths=?ev.paths, "ignoring event"); - return Ok(()); - } - } - - ev.paths - .iter() - .filter_map(|path| match path.extension().and_then(|e| e.to_str())? { - "torrent" => match read_and_validate_torrent(path) { - Ok(add_torrent) => Some((add_torrent, path)), - Err(e) => { - warn!(?path, "torrent file invalid: {e:#}"); - None - } - }, - "magnet" => match read_and_validate_magnet(path) { - Ok(add_torrent) => Some((add_torrent, path)), - Err(e) => { - warn!(?path, "magnet file invalid: {e:#}"); - None - } - }, - _ => { - trace!(?path, "ignoring path"); - None - } - }) - .for_each(|(add_torrent, path)| { - if let Err(e) = tx.send((add_torrent, path.to_owned())) { - error!("watch thread couldn't send message: {e:#}"); - } - }); - - Ok(()) - } - - for entry in WalkDir::new(&folder) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - .filter(|e| e.path().extension().and_then(|e| e.to_str()) == Some("torrent")) - { - let t = match read_and_validate_torrent(entry.path()) { - Ok(t) => t, - Err(e) => { - warn!(path=?entry.path(), "error validating torrent: {e:#}"); - continue; - } - }; - if tx.send((t, entry.path().to_owned())).is_err() { - debug!(?folder, "watcher thread done"); - return Ok(()); - } - } - - let mut watcher = notify::recommended_watcher(move |ev| { - if let Err(e) = watch_cb(ev, &tx) { - warn!("error processing watch event: {e:#}"); - } - }) - .context("error creating watcher")?; - watcher - .watch(&folder, notify::RecursiveMode::Recursive) - .context("error watching")?; - cancel_event.wait_until_cancelled(); - debug!(?folder, "watcher thread done"); - Ok(()) -} - -impl Session { - pub fn watch_folder(self: &Arc, watch_folder: &Path) { - let session_w = Arc::downgrade(self); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - self.spawn( - debug_span!("watch_adder", ?watch_folder), - "watch_adder", - async move { - watch_adder(session_w, rx).await; - Ok(()) - }, - ); - - let cancel_event = ThreadCancelEvent::new(); - let cancel_event_2 = cancel_event.clone(); - let cancel_token = self.cancellation_token().clone(); - spawn( - debug_span!("watch_cancel", ?watch_folder), - "watch_cancel", - async move { - cancel_token.cancelled().await; - trace!("canceling watcher"); - cancel_event.cancel(); - Ok::<_, &'static str>(()) - }, - ); - - let watch_folder = PathBuf::from(watch_folder); - let session_span = self.rs(); - std::thread::spawn(move || { - let span = debug_span!(parent: session_span, "watcher", folder=?watch_folder); - span.in_scope(move || { - if let Err(e) = watch_thread(watch_folder, tx, &cancel_event_2) { - error!("error in watcher thread: {e:#}"); - } - }) - }); - } -} diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/.gitignore b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/.gitignore deleted file mode 100644 index dfe9608b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist-ssr -*.local - -dist/ - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/CLAUDE.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/CLAUDE.md deleted file mode 100644 index bdbc6d62..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/CLAUDE.md +++ /dev/null @@ -1,279 +0,0 @@ -# WebUI Architecture Guide - -This document helps Claude work efficiently on the rtbit webui codebase. - -## Tech Stack -- React 18 + TypeScript -- Tailwind CSS (dark mode via `dark:` prefix) -- Zustand for state management -- Vite for dev/build -- react-icons for icons (BsX, FaX, MdX, GoX prefixes) - -## Directory Structure -``` -src/ -├── api-types.ts # TypeScript types matching backend API -├── http-api.ts # API client (all backend calls) -├── context.tsx # React contexts (APIContext) -├── rtbit-web.tsx # App shell, header, menu buttons -├── main.tsx # Entry point -├── stores/ # Zustand stores -│ ├── torrentStore.ts # Global torrent list, loading states -│ ├── uiStore.ts # View mode, selection state, page navigation (torrents/indexarr) -│ ├── indexarrStore.ts # Indexarr integration state (search, recent, identity, setup) -│ ├── errorStore.ts # Alerts and errors -│ └── statsStore.ts # Session-wide stats -├── hooks/ # Custom React hooks -├── helper/ # Utility functions (formatBytes, etc.) -└── components/ - ├── RootContent.tsx # Main content area, layout switching + Indexarr page routing - ├── IndexarrBrowse.tsx # Indexarr search/recent page with download flow - ├── IndexarrSetup.tsx # Indexarr first-boot setup (identity + categories) - ├── CardLayout.tsx # Card view layout (list of cards) - ├── TorrentCard.tsx # Card view data wrapper per torrent - ├── TorrentCardContent.tsx # Card view single torrent content - ├── compact/ # Compact/table view components - │ ├── CompactLayout.tsx # Table view layout - │ ├── TorrentTable.tsx # Table with headers - │ ├── TorrentTableRow.tsx # Single table row - │ ├── ActionBar.tsx # Bulk action buttons - │ ├── DetailPane.tsx # Bottom detail panel - │ └── *Tab.tsx # Detail tabs - ├── buttons/ # Reusable buttons - ├── modal/ # Modal dialogs - └── forms/ # Form components -``` - -## Key Patterns - -### Data Fetching (per-torrent) -Each torrent fetches its own data independently. Pattern from `TorrentCard.tsx`: -```typescript -// Details: fetch once, retry on error -useEffect(() => { - return loopUntilSuccess(async () => { - await API.getTorrentDetails(id).then(setDetails); - }, 1000); -}, [forceRefresh]); - -// Stats: continuous polling with adaptive interval -useEffect(() => { - return customSetInterval(async () => { - return API.getTorrentStats(id).then(stats => { - setStats(stats); - // Fast poll (1s) when live, slow (10s) when paused - return stats.state === "live" ? 1000 : 10000; - }); - }, 0); -}, [forceRefresh]); -``` - -### State Management -Zustand stores are simple - just use hooks: -```typescript -// Reading state -const viewMode = useUIStore(state => state.viewMode); -const torrents = useTorrentStore(state => state.torrents); - -// Actions -const selectTorrent = useUIStore(state => state.selectTorrent); -selectTorrent(id); -``` - -### Responsive Design -- Breakpoint: `lg` (1024px) for compact vs card view -- Use `useIsLargeScreen()` hook for JS logic -- Use Tailwind classes for CSS: `lg:flex-row`, `hidden lg:block` - -### Dark Mode -Always add dark variants: `bg-white dark:bg-slate-800` - -## API Types (api-types.ts) -Key types to know: -- `TorrentId`: `{ id: number, info_hash: string }` -- `TorrentDetails`: `{ name, info_hash, files[] }` -- `TorrentStats`: `{ state, error, progress_bytes, total_bytes, finished, live? }` -- `LiveTorrentStats`: speeds, ETA, peer_stats (only when state="live") - -States: `"initializing"`, `"live"`, `"paused"`, `"error"` - -## API Methods (http-api.ts) -```typescript -API.listTorrents() // GET /torrents -API.getTorrentDetails(id) // GET /torrents/{id} -API.getTorrentStats(id) // GET /torrents/{id}/stats/v1 -API.start(id) // POST /torrents/{id}/start -API.pause(id) // POST /torrents/{id}/pause -API.forget(id) // POST /torrents/{id}/forget (remove from list) -API.delete(id) // POST /torrents/{id}/delete (remove + delete files) -API.updateOnlyFiles(id, fileIds[]) // POST /torrents/{id}/update_only_files -``` - -## Adding New Features - -### New Component -1. Create in appropriate directory (`components/` or `components/compact/`) -2. Use existing patterns for data fetching if needed -3. Import from parent component - -### New Store State -1. Add to existing store or create new in `stores/` -2. Follow Zustand pattern: `create((set, get) => ({...}))` - -### New API Call -1. Add type to `api-types.ts` -2. Add method to `http-api.ts` using `makeRequest()` - -## Common Tasks - -### Show loading state -```typescript -if (!data) return ; -``` - -### Show error -```typescript -const setCloseableError = useErrorStore(state => state.setCloseableError); -setCloseableError({ text: "Error message", details: error }); -``` - -### Refresh torrent data -```typescript -const refreshTorrents = useTorrentStore(state => state.refreshTorrents); -refreshTorrents(); -``` - -### Format display values -```typescript -import { formatBytes } from "../helper/formatBytes"; -import { getCompletionETA } from "../helper/getCompletionETA"; -import { torrentDisplayName } from "../helper/getTorrentDisplayName"; -``` - -## Testing Changes -```bash -# Dev server (hot reload) -npm run dev # or: make webui-dev from repo root - -# Type check -npx tsc --noEmit - -# Format code (run from repo root) -npm run format - -# Build -npm run build -``` - -**Always run `npm run format` from the repo root after modifying TypeScript/TSX files.** - -Dev server runs at http://localhost:3031/, connects to backend at :3030. - -## Mock Mode (No Backend Required) - -For UI testing without a real rtbit server, use mock mode: - -```bash -npm run dev:mock -``` - -This starts the dev server on port 3032 and opens http://localhost:3032/mock.html with: -- 1000 generated torrents (Linux distro names) -- ~30 active (live/initializing), rest paused -- Simulated download progress for live torrents -- Stable peer IPs with incrementing counters (for speed calculations) -- Working pause/start/forget/delete actions - -Mock code (`mock-api.ts`, `main-mock.tsx`, `mock.html`) is excluded from production builds. - -Use this to test UI performance, layout with many torrents, or develop without running the full stack. - -## Performance Guidelines - -When working with large lists (1000+ torrents), follow these patterns: - -### Virtualization -Both card and table views use `react-virtuoso` for virtualization - only visible items are rendered to the DOM. See `architecture/virtualization.md` for full details. - -**Key requirements:** -- Parent container chain must have explicit height (use `h-full`, `flex-1 min-h-0`) -- Must filter array before passing to Virtuoso (can't use CSS hidden) - -**Benefits:** -- Variable height items work automatically (no fixed `itemSize` needed) -- No `AutoSizer` wrapper required -- Simpler API: just `totalCount` and `itemContent` props -- DOM stays small (~500 elements vs 44,000), initial render is 8x faster - -### Memoization -- Use `memo()` for row/card components that receive torrent data -- Use `useMemo()` for expensive computations (sorting, filtering for navigation) -- Use `useCallback()` for handlers passed to child components - -### Debouncing -- Always debounce search input (150ms is good) -- Use local state for instant feedback, debounced update to store: -```typescript -const [localSearch, setLocalSearch] = useState(searchQuery); -const debouncedSetSearch = useCallback( - debounce((value: string) => setSearchQuery(value), 150), - [setSearchQuery] -); -``` - -### Shared Utilities -Common filtering/sorting logic is in `helper/torrentFilters.ts`: -- `isTorrentVisible(t, query, statusFilter)` - combined visibility check -- `compareTorrents(a, b, column, direction)` - sorting comparison -- Type definitions: `TorrentSortColumn`, `SortDirection`, `StatusFilter` - -## Code Style - -### Avoid Repetitive CSS Classes -When the same Tailwind class combination appears 3+ times, extract it into a variable: - -```typescript -// BAD - repetitive, hard to maintain - - - - -// GOOD - extracted into variable -const numericCell = "w-20 px-2 text-right text-secondary whitespace-nowrap align-middle"; - - - -``` - -For more complex cases, consider a small component or use template literal composition: -```typescript -const cellBase = "px-2 align-middle"; -const numericCell = `w-20 ${cellBase} text-right text-secondary whitespace-nowrap`; -``` - -## Indexarr Integration - -Optional torrent index browsing powered by an external Indexarr instance. Controlled by `RTBIT_INDEXARR_ENABLED`, `RTBIT_INDEXARR_URL`, `RTBIT_INDEXARR_API_KEY` env vars. See `documentation/IndexarrRustTorrentIntegration.md` in the repo root for the full integration design. - -### Page Navigation -- `uiStore.currentPage`: `"torrents" | "indexarr"` — controls which page is shown -- Toolbar has tab-style nav (Torrents / Browse Index) in the top bar -- Sidebar clicks automatically switch back to `"torrents"` -- Sidebar is hidden on the Indexarr page - -### Key Components -- `IndexarrBrowse.tsx` — Search tab (debounced, filtered) + Recent tab (60s polling) + download buttons -- `IndexarrSetup.tsx` — First-boot: save recovery key, select sync categories -- `RootContent.tsx` — Routes between torrent view and Indexarr view based on `currentPage` - -### API Client -- `IndexarrAPI` object in `http-api.ts` — separate from the main `API` object -- All calls go through rtbit's `/indexarr/*` proxy endpoints (not directly to Indexarr) -- Types in `api-types.ts`: `IndexarrSearchResult`, `IndexarrRecentItem`, `IndexarrIdentityStatus`, etc. - -### Download Flow -1. User clicks download button on a search/recent result -2. `buildMagnet()` constructs magnet URI from info_hash + name + stored tracker URLs -3. `API.uploadTorrent(magnet, { list_only: true })` resolves metadata via rtbit -4. `FileSelectionModal` opens — user picks files -5. Normal rtbit download flow proceeds diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/architecture/virtualization.md b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/architecture/virtualization.md deleted file mode 100644 index 7bcb0664..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/architecture/virtualization.md +++ /dev/null @@ -1,263 +0,0 @@ -# Virtualization in rtbit WebUI - -This document explains how list virtualization works in the rtbit web interface. - -## Performance Results - -| Metric | Before | After | Improvement | -|--------|--------|-------|-------------| -| LCP (Largest Contentful Paint) | 1,175 ms | 141 ms | **8.3x faster** | -| DOM elements | 44,228 | ~500 | **99% reduction** | -| Render delay | 1,168 ms | 138 ms | **8.5x faster** | - -Tested with 1000 mock torrents. - -## The Problem - -With 1000 torrents, each card has ~44 DOM elements = 44,000 DOM nodes. The browser must: -1. Create all 44K nodes on initial render -2. Calculate styles for all 44K nodes -3. Layout all 44K nodes -4. Keep all 44K nodes in memory - -This causes: -- Slow initial page load (~1.2 seconds) -- Sluggish scrolling -- High memory usage -- Slow filter/search updates (all nodes re-rendered) - -## The Solution - react-virtuoso - -Virtualization renders **only the visible items**. If your viewport shows 6 cards, only those 6 cards exist in the DOM. - -``` -┌─────────────────────────────────┐ -│ Card 0 ← rendered │ ← viewport top -│ Card 1 ← rendered │ -│ Card 2 ← rendered │ -│ Card 3 ← rendered │ -│ Card 4 ← rendered │ -│ Card 5 ← rendered │ ← viewport bottom -├─────────────────────────────────┤ -│ Card 6 ← NOT in DOM │ -│ Card 7 ← NOT in DOM │ -│ ... │ -│ Card 999 ← NOT in DOM │ -└─────────────────────────────────┘ -``` - -As you scroll, react-virtuoso: -1. Removes items that scroll out of view (unmounts React components) -2. Creates items that scroll into view (mounts new components) -3. **Measures item heights dynamically** - no fixed height required! - -## Implementation - -### Key Dependencies - -```json -{ - "react-virtuoso": "^4.12.5" -} -``` - -### Why react-virtuoso over react-window? - -| Feature | react-window | react-virtuoso | -|---------|--------------|----------------| -| Variable height items | Requires `VariableSizeList` + height function | Automatic measurement | -| Container sizing | Requires `AutoSizer` wrapper | Automatic | -| API complexity | `itemSize`, `itemData`, `style` prop | Just `totalCount` + `itemContent` | -| Mobile experience | Poor (fixed heights don't adapt) | Great (measures actual content) | - -### Card View Implementation - -```typescript -import { Virtuoso } from "react-virtuoso"; - -// Item renderer - receives index, returns JSX -const itemContent = useCallback( - (index: number) => { - const torrent = filteredTorrents![index]; - return ( -
- -
- ); - }, - [filteredTorrents] -); - -return ( -
- -
-); -``` - -That's it! No `AutoSizer`, no `itemSize`, no `style` prop to pass through. - -### Table View Implementation - -Table view uses the same pattern. Each row wraps a `
..................
` to maintain column alignment with the header. - -```typescript -// TorrentTable.tsx -const itemContent = useCallback( - (index: number) => { - const torrent = filteredTorrents![index]; - return ( - - ); - }, - [filteredTorrents, selectedTorrentIds, handleRowClick, toggleSelection] -); - -return ( -
- {/* Fixed header */} -
- ... -
- {/* Virtualized body */} -
- -
-
-); -``` - -```typescript -// TorrentTableRow.tsx - No style prop needed! -return ( - - - - - - {/* ... more cells with explicit widths */} - - -
......
-); -``` - -### Parent Height Chain - -react-virtuoso still needs a parent with explicit height. This is the most common gotcha. - -**Required CSS chain:** -``` -html, body: height: 100% - └─ App container: h-screen flex flex-col - └─ Content area: flex-1 min-h-0 (or grow min-h-0) - └─ Virtuoso container: flex-1 min-h-0 - └─ Virtuoso (fills container automatically) -``` - -Key classes: -- `h-screen` or `h-full` - explicit height -- `flex-1` - grow to fill available space -- `min-h-0` - allow shrinking below content size (critical for flex children!) - -Without `min-h-0`, flex children have implicit `min-height: auto` which prevents shrinking. - -## Hacks and Fixes - -### 1. Hidden Scrollbar - -**Problem**: Virtuoso creates a scrollable container with visible scrollbar, which looked bad in card view. - -**Solution**: CSS to hide scrollbar while preserving scroll functionality. - -```css -/* globals.css */ -.hide-scrollbar { - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ -} -.hide-scrollbar::-webkit-scrollbar { - display: none; /* Chrome/Safari/Opera */ -} -``` - -Apply to the Virtuoso: `className="hide-scrollbar"` - -### 2. Table Row Height Consistency - -**Problem**: Variable padding on table cells can cause inconsistent row heights. - -**Solution**: Use explicit row height + vertical alignment. - -```typescript - - ... - -``` - -## Trade-offs and Downsides - -### 1. Parent Height Required -If any parent in the chain lacks explicit height, Virtuoso may not render correctly. - -**Debugging:** Check if the container has height using browser dev tools. - -### 2. Array Filtering Required -Unlike CSS `hidden` approach, you MUST filter the array before passing to Virtuoso. - -```typescript -// With virtualization - filter first -const filtered = items.filter(item => matchesSearch(item)); - } -/> - -// Without virtualization - CSS hidden (doesn't work with virtualization!) -items.map(item => ( - - ); -}; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/src/components/IndexarrBrowse.tsx b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/src/components/IndexarrBrowse.tsx deleted file mode 100644 index cd0a058b..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/src/components/IndexarrBrowse.tsx +++ /dev/null @@ -1,655 +0,0 @@ -import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { - FaDownload, - FaSearch, - FaSeedling, - FaUsers, - FaCog, -} from "react-icons/fa"; -import { GoX } from "react-icons/go"; -import debounce from "lodash.debounce"; - -import { APIContext } from "../context"; -import { - AddTorrentResponse, - ErrorDetails, - IndexarrSearchResult, - IndexarrRecentItem, -} from "../api-types"; -import { useIndexarrStore } from "../stores/indexarrStore"; -import { useErrorStore } from "../stores/errorStore"; -import { formatBytes } from "../helper/formatBytes"; -import { IndexarrAPI } from "../http-api"; -import { Spinner } from "./Spinner"; -import { IndexarrSetup } from "./IndexarrSetup"; -import { FileSelectionModal } from "./modal/FileSelectionModal"; -import { ErrorWithLabel } from "../rtbit-web"; - -const DEFAULT_TRACKERS = [ - "udp://tracker.opentrackr.org:1337/announce", - "udp://open.stealth.si:80/announce", -]; - -function buildMagnet( - infoHash: string, - name: string | null, - trackers: string[] | null, -): string { - const dn = name ? `&dn=${encodeURIComponent(name)}` : ""; - const trList = trackers && trackers.length > 0 ? trackers : DEFAULT_TRACKERS; - const tr = trList.map((t) => `&tr=${encodeURIComponent(t)}`).join(""); - return `magnet:?xt=urn:btih:${infoHash}${dn}${tr}`; -} - -function contentTypeBadge(ct: string | null) { - if (!ct) return null; - const colors: Record = { - movie: "bg-blue-500/20 text-blue-400", - tv_show: "bg-purple-500/20 text-purple-400", - music: "bg-green-500/20 text-green-400", - ebook: "bg-yellow-500/20 text-yellow-400", - game: "bg-red-500/20 text-red-400", - software: "bg-cyan-500/20 text-cyan-400", - xxx: "bg-pink-500/20 text-pink-400", - }; - const color = colors[ct] || "bg-surface text-secondary"; - const label = ct.replace("_", " "); - return ( - - {label} - - ); -} - -function timeAgo(isoDate: string | null): string { - if (!isoDate) return ""; - const diff = Date.now() - new Date(isoDate).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -const CONTENT_TYPES = [ - "", - "movie", - "tv_show", - "music", - "ebook", - "comic", - "audiobook", - "game", - "software", -]; - -export const IndexarrBrowse = () => { - const API = useContext(APIContext); - const setCloseableError = useErrorStore((s) => s.setCloseableError); - - const status = useIndexarrStore((s) => s.status); - const identity = useIndexarrStore((s) => s.identity); - const setIdentity = useIndexarrStore((s) => s.setIdentity); - const showSetup = useIndexarrStore((s) => s.showSetup); - const setShowSetup = useIndexarrStore((s) => s.setShowSetup); - - const searchQuery = useIndexarrStore((s) => s.searchQuery); - const setSearchQuery = useIndexarrStore((s) => s.setSearchQuery); - const searchFilters = useIndexarrStore((s) => s.searchFilters); - const setSearchFilter = useIndexarrStore((s) => s.setSearchFilter); - const searchResults = useIndexarrStore((s) => s.searchResults); - const searchTotal = useIndexarrStore((s) => s.searchTotal); - const searchLoading = useIndexarrStore((s) => s.searchLoading); - const setSearchResults = useIndexarrStore((s) => s.setSearchResults); - const setSearchLoading = useIndexarrStore((s) => s.setSearchLoading); - - const recentTorrents = useIndexarrStore((s) => s.recentTorrents); - const recentLoading = useIndexarrStore((s) => s.recentLoading); - const setRecentTorrents = useIndexarrStore((s) => s.setRecentTorrents); - const setRecentLoading = useIndexarrStore((s) => s.setRecentLoading); - - const [localSearch, setLocalSearch] = useState(searchQuery); - const [downloadingHash, setDownloadingHash] = useState(null); - const [activeTab, setActiveTab] = useState<"search" | "recent">("search"); - - // File selection modal state for download flow - const [pendingMagnet, setPendingMagnet] = useState(null); - const [listTorrentResponse, setListTorrentResponse] = - useState(null); - const [listTorrentLoading, setListTorrentLoading] = useState(false); - const [listTorrentError, setListTorrentError] = - useState(null); - - const recentIntervalRef = useRef | null>(null); - - // Check identity on mount - useEffect(() => { - IndexarrAPI.getIdentityStatus() - .then(setIdentity) - .catch(() => {}); - }, []); - - // Load recent torrents + poll every 60s - const loadRecent = useCallback(async () => { - setRecentLoading(true); - try { - const resp = await IndexarrAPI.getRecent(50); - setRecentTorrents(resp.results); - } catch { - // silent - } finally { - setRecentLoading(false); - } - }, []); - - useEffect(() => { - loadRecent(); - recentIntervalRef.current = setInterval(loadRecent, 60_000); - return () => { - if (recentIntervalRef.current) clearInterval(recentIntervalRef.current); - }; - }, [loadRecent]); - - // Search - const doSearch = useCallback( - async (query: string, filters: Record) => { - setSearchLoading(true); - try { - const resp = await IndexarrAPI.search(query, filters); - setSearchResults(resp.results, resp.total, resp.offset); - } catch (e: any) { - setCloseableError({ - text: "Indexarr search failed", - details: e, - }); - } finally { - setSearchLoading(false); - } - }, - [], - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedSearch = useCallback( - debounce((q: string, f: Record) => doSearch(q, f), 300), - [doSearch], - ); - - const handleSearchChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setLocalSearch(value); - setSearchQuery(value); - debouncedSearch(value, searchFilters); - }; - - const handleFilterChange = (key: string, value: string) => { - setSearchFilter(key, value); - const newFilters = { ...searchFilters, [key]: value }; - debouncedSearch(searchQuery, newFilters); - }; - - const clearSearch = () => { - setLocalSearch(""); - setSearchQuery(""); - setSearchResults([], 0, 0); - }; - - // When pendingMagnet is set, fetch file list for the FileSelectionModal - useEffect(() => { - if (!pendingMagnet) return; - - let cancelled = false; - setListTorrentLoading(true); - setListTorrentResponse(null); - setListTorrentError(null); - - (async () => { - try { - const response = await API.uploadTorrent(pendingMagnet, { - list_only: true, - }); - if (!cancelled) setListTorrentResponse(response); - } catch (e) { - if (!cancelled) - setListTorrentError({ - text: "Error listing torrent files", - details: e as ErrorDetails, - }); - } finally { - if (!cancelled) setListTorrentLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [pendingMagnet]); - - const clearPendingDownload = () => { - setPendingMagnet(null); - setListTorrentResponse(null); - setListTorrentError(null); - setListTorrentLoading(false); - setDownloadingHash(null); - }; - - // Download handler — constructs magnet and opens file selection modal - const handleDownload = ( - infoHash: string, - name: string | null, - trackers: string[] | null, - ) => { - setDownloadingHash(infoHash); - const magnet = buildMagnet(infoHash, name, trackers); - setPendingMagnet(magnet); - }; - - // Not connected - if (status && !status.enabled) { - return ( -
-

Indexarr integration is not enabled.

-
- ); - } - - if (status && status.enabled && !status.reachable) { - return ( -
-
-

Cannot reach Indexarr

-

{status.error}

-
-
- ); - } - - // Setup needed - if (identity && identity.needs_onboarding) { - return ; - } - - // Show setup panel - if (showSetup) { - return ; - } - - const cellBase = "px-3 py-2 text-sm"; - const headerCell = `${cellBase} font-medium text-secondary text-left`; - const numCell = `${cellBase} text-right whitespace-nowrap`; - - return ( -
- {/* Header bar */} -
- {/* Tab buttons */} - - - -
- - {/* Setup gear */} - -
- - {activeTab === "search" && ( -
- {/* Search input + filters */} -
-
-
- - - {localSearch && ( - - )} -
-
- {/* Filters row */} -
- - - -
-
- - {/* Results */} -
- {searchLoading && searchResults.length === 0 ? ( -
- -
- ) : searchResults.length === 0 && searchQuery ? ( -
- No results found -
- ) : searchResults.length === 0 ? ( -
- Enter a search query to find torrents -
- ) : ( - <> -
- {searchTotal.toLocaleString()} results - {searchLoading && " (loading...)"} -
- - - - - - - - - - - - - {searchResults.map((r) => ( - - handleDownload(r.info_hash, r.name, r.trackers) - } - /> - ))} - -
Name
- - )} -
-
- )} - - {activeTab === "recent" && ( -
- {recentLoading && recentTorrents.length === 0 ? ( -
- -
- ) : recentTorrents.length === 0 ? ( -
- No recent torrents -
- ) : ( - - - - - - - - - - - - {recentTorrents.map((r) => ( - - handleDownload(r.info_hash, r.name, r.trackers) - } - /> - ))} - -
Name
- )} -
- )} - - {/* File selection modal for downloads */} - {pendingMagnet && ( - - )} -
- ); -}; - -// --- Row components --- - -const SearchRow = ({ - result, - downloading, - onDownload, -}: { - result: IndexarrSearchResult; - downloading: boolean; - onDownload: () => void; -}) => { - const cellBase = "px-3 py-2 text-sm"; - const numCell = `${cellBase} text-right whitespace-nowrap`; - - return ( - - -
-
-
- {result.name || result.info_hash} -
-
- {contentTypeBadge(result.content_type)} - {result.resolution && ( - - {result.resolution} - - )} - {result.codec && ( - - {result.codec} - - )} - {result.year && ( - - {result.year} - - )} -
-
-
- - - {result.size ? formatBytes(result.size) : "-"} - - - - - {result.seed_count} - - - - - - {result.peer_count} - - - - {timeAgo(result.resolved_at)} - - - - - - ); -}; - -const RecentRow = ({ - item, - downloading, - onDownload, -}: { - item: IndexarrRecentItem; - downloading: boolean; - onDownload: () => void; -}) => { - const cellBase = "px-3 py-2 text-sm"; - const numCell = `${cellBase} text-right whitespace-nowrap`; - - return ( - - -
-
- {item.name || item.info_hash} -
-
- {contentTypeBadge(item.content_type)} - {item.resolution && ( - - {item.resolution} - - )} -
-
- - - {item.size ? formatBytes(item.size) : "-"} - - - - - {item.seed_count} - - - - {timeAgo(item.resolved_at)} - - - - - - ); -}; diff --git a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/src/components/IndexarrSetup.tsx b/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/src/components/IndexarrSetup.tsx deleted file mode 100644 index 3a57e8e7..00000000 --- a/.claude/worktrees/agent-aa2f0435/crates/torrent/librtbit/webui/src/components/IndexarrSetup.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useEffect, useState } from "react"; -import { FaArrowLeft, FaCheck, FaCopy } from "react-icons/fa"; - -import { IndexarrAPI } from "../http-api"; -import { useIndexarrStore } from "../stores/indexarrStore"; -import { useErrorStore } from "../stores/errorStore"; -import { Spinner } from "./Spinner"; - -export const IndexarrSetup = () => { - const identity = useIndexarrStore((s) => s.identity); - const setIdentity = useIndexarrStore((s) => s.setIdentity); - const syncPreferences = useIndexarrStore((s) => s.syncPreferences); - const setSyncPreferences = useIndexarrStore((s) => s.setSyncPreferences); - const setShowSetup = useIndexarrStore((s) => s.setShowSetup); - const setCloseableError = useErrorStore((s) => s.setCloseableError); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [copied, setCopied] = useState(false); - const [selectedCategories, setSelectedCategories] = useState([]); - const [syncComments, setSyncComments] = useState(true); - const [step, setStep] = useState<"identity" | "categories">("identity"); - - // Load identity + preferences on mount - useEffect(() => { - (async () => { - try { - const [id, prefs] = await Promise.all([ - IndexarrAPI.getIdentityStatus(), - IndexarrAPI.getSyncPreferences(), - ]); - setIdentity(id); - setSyncPreferences(prefs); - setSelectedCategories(prefs.import_categories); - setSyncComments(prefs.sync_comments); - - // Skip identity step if already acknowledged - if (!id.needs_onboarding) { - setStep("categories"); - } - } catch (e: any) { - setCloseableError({ - text: "Failed to load Indexarr setup", - details: e, - }); - } finally { - setLoading(false); - } - })(); - }, []); - - const handleCopyKey = async () => { - if (identity?.recovery_key) { - try { - await navigator.clipboard.writeText(identity.recovery_key); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // fallback: select textarea - } - } - }; - - const handleAcknowledge = async () => { - setSaving(true); - try { - await IndexarrAPI.acknowledgeIdentity(); - const updated = await IndexarrAPI.getIdentityStatus(); - setIdentity(updated); - setStep("categories"); - } catch (e: any) { - setCloseableError({ - text: "Failed to acknowledge identity", - details: e, - }); - } finally { - setSaving(false); - } - }; - - const toggleCategory = (cat: string) => { - setSelectedCategories((prev) => - prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat], - ); - }; - - const handleSavePreferences = async () => { - setSaving(true); - try { - const result = await IndexarrAPI.setSyncPreferences({ - import_categories: selectedCategories, - sync_comments: syncComments, - }); - setSyncPreferences(result); - setShowSetup(false); - } catch (e: any) { - setCloseableError({ - text: "Failed to save sync preferences", - details: e, - }); - } finally { - setSaving(false); - } - }; - - if (loading) { - return ( -
- -
- ); - } - - return ( -
-
- {/* Header */} -
- {identity && !identity.needs_onboarding && ( - - )} -

Indexarr Setup

-
- - {step === "identity" && ( -
-

- Your Indexarr node has generated a unique contributor identity. - Save the recovery key below — you will need it to restore your - identity if you reinstall. -

- - {identity?.contributor_id && ( -
- - - {identity.contributor_id} - -
- )} - - {identity?.recovery_key && ( -
- -
-