diff --git a/.cursor/agents/thermo-nuclear-code-quality-review.md b/.cursor/agents/thermo-nuclear-code-quality-review.md new file mode 100644 index 0000000..407657d --- /dev/null +++ b/.cursor/agents/thermo-nuclear-code-quality-review.md @@ -0,0 +1,12 @@ +--- +name: thermo-nuclear-code-quality-review +description: Thermo-nuclear code quality audit (maintainability, structure, 1k-line rule, spaghetti, code-judo). Use for deep maintainability reviews, harsh code quality audits, or when the user asks for thermo-nuclear review. +--- + +# Thermo-Nuclear Code Quality Review + +You are a **Task subagent** for arr-dashboard. Read `~/.cursor/skills/thermo-nuclear-code-quality-review/SKILL.md` as the complete rubric. + +Apply the rubric to assigned files. Output findings in rubric priority order. When asked to implement fixes, make minimal behavior-preserving structural improvements: decompose files over 1k lines, delete duplicate modules, extract helpers, collapse spaghetti branches. + +Do not spawn nested subagents unless explicitly asked. diff --git a/.cursor/skills/thermo-nuclear-code-quality-review/SKILL.md b/.cursor/skills/thermo-nuclear-code-quality-review/SKILL.md new file mode 100644 index 0000000..26a8690 --- /dev/null +++ b/.cursor/skills/thermo-nuclear-code-quality-review/SKILL.md @@ -0,0 +1,52 @@ +--- +name: thermo-nuclear-code-quality-review +description: Run an extremely strict maintainability review for abstraction quality, giant files, and spaghetti-condition growth. Use for a thermo-nuclear code quality review, thermonuclear review, deep code quality audit, or especially harsh maintainability review. +disable-model-invocation: true +--- + +# Thermo-Nuclear Code Quality Review + +Use this skill for an unusually strict review focused on implementation quality, maintainability, abstraction quality, and codebase health. + +Above all, this skill should push the reviewer to be **ambitious** about code structure. Do not merely identify local cleanup opportunities. Actively search for "code judo" moves: restructurings that preserve behavior while making the implementation dramatically simpler, smaller, more direct, and more elegant. + +## Core Prompt + +Start from this baseline: + +> Perform a deep code quality audit of the current branch's changes. +> Rethink how to structure / implement the changes to meaningfully improve code quality without impacting behavior. +> Work to improve abstractions, modularity, reduce Spaghetti code, improve succinctness and legibility. +> Be ambitious, if there is a clear path to improving the implementation that involves restructuring some of the codebase, go for it. +> Be extremely thorough and rigorous. Measure twice, cut once. + +## Non-Negotiable Additional Standards + +Apply the baseline prompt above, plus these explicit review rules: + +0. **Be ambitious about structural simplification.** +1. **Do not let a PR push a file from under 1k lines to over 1k lines without a very strong reason.** +2. **Do not allow random spaghetti growth in existing code.** +3. **Bias toward cleaning the design, not just accepting working code.** +4. **Prefer direct, boring, maintainable code over hacky or magical code.** +5. **Push hard on type and boundary cleanliness when they affect maintainability.** +6. **Keep logic in the canonical layer and reuse existing helpers.** +7. **Treat unnecessary sequential orchestration and non-atomic updates as design smells when the cleaner structure is obvious.** + +See the full rubric at `~/.cursor/skills/thermo-nuclear-code-quality-review/SKILL.md`. + +## Output Expectations + +Prioritize findings in this order: + +1. Structural code-quality regressions +2. Missed opportunities for dramatic simplification / code-judo restructuring +3. Spaghetti / branching complexity increases +4. Boundary / abstraction / type-contract problems +5. File-size and decomposition concerns +6. Modularity and abstraction issues +7. Legibility and maintainability concerns + +## Approval Bar + +Do not approve merely because behavior seems correct. Block on unjustified file-size explosion, spaghetti growth, duplicate modules, and missed decomposition opportunities. diff --git a/.env.example b/.env.example index 4c40a2b..ea17591 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ -# ─── Required ──────────────────────────────────────────────────────────────── -# For direct non-installer startup, get these from your Radarr/Sonarr/Lidarr settings > General > API Key. -# Web installer flow can populate these interactively at first run, so they can remain blank. +# ─── Setup / onboarding ────────────────────────────────────────────────────── +INSTALLER_ENABLED=true +SETUP_BOOTSTRAP_TOKEN= + +# ─── Arr services ──────────────────────────────────────────────────────────── +# Get these from Radarr/Sonarr/Lidarr settings > General > API Key. +# They can stay blank for first boot; finish setup from the dashboard UI. RADARR_API_KEY= SONARR_API_KEY= LIDARR_API_KEY= @@ -13,29 +17,16 @@ LIDARR_API_KEY= # LIDARR_HOST=http://lidarr:8686 # QBITTORRENT_HOST=http://qbittorrent:8080 # SLSKD_HOST=http://slskd:5030 -# PROWLARR_HOST=http://prowlarr:9696 -# ─── API Keys / Credentials (optional overrides) ─────────────────────────── +# ─── qBittorrent (required for torrent download tracking) ──────────────────── # QBITTORRENT_USER=admin # QBITTORRENT_PASS=adminadmin -# SLSKD_API_KEY= -# PROWLARR_API_KEY= - -# ─── Installer mode ───────────────────────────────────────────────────────── -# Fresh installs default to web onboarding in the dashboard Settings view. -# Set to false only if you are supplying the required Arr API keys yourself. -INSTALLER_ENABLED=true - -# Dashboard UI port mapped to the host by the frontend container. -DASHBOARD_PORT=8888 -# ─── Installer state (web-installer-first path) ─────────────────────────── -INSTALLER_STATE_HOST_PATH=./backend/installer-state.json -INSTALLER_STATE_PATH=/app/installer-state.json - -# Compose creates or reuses this named bridge network automatically. -ARR_NETWORK_NAME=arr-network +# ─── SLSKD / Soulseek (required for Soulseek download tracking) ───────────── +# SLSKD_API_KEY= # ─── Server ────────────────────────────────────────────────────────────────── # PORT=3000 # NODE_ENV=development +# DASHBOARD_PORT=8888 +# ARR_NETWORK_NAME=arr-dashboard-network diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68ffebc..b28c8df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,48 +2,11 @@ name: CI on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] + branches: [main] jobs: - docs: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Reject stale repository URLs in docs - shell: bash - run: | - set -euo pipefail - origin_url="$(git remote get-url origin)" - canonical_url="${origin_url%.git}" - canonical_url="${canonical_url/git@github.com:/https://github.com/}" - canonical_url="${canonical_url}.git" - allowed_placeholder='https://github.com/YOUR_GITHUB_USERNAME/vibarr.git' - scan_paths=() - for path in README.md SETUP.md CONTRIBUTING.md docs .env.example install.sh .github; do - if [ -e "$path" ]; then - scan_paths+=("$path") - fi - done - - matches="$(grep -RInE 'https://github\.com/[^/]+/vibarr\.git|https://raw\.githubusercontent\.com/[^/]+/vibarr/' "${scan_paths[@]}" || true)" - - if [ -z "$matches" ]; then - exit 0 - fi - - bad_matches="$(printf '%s\n' "$matches" | grep -vF "$canonical_url" | grep -vF "$allowed_placeholder" || true)" - - if [ -n "$bad_matches" ]; then - echo "Found stale or non-canonical repository URLs:" - printf '%s\n' "$bad_matches" - echo "Canonical URL is: $canonical_url" - exit 1 - fi - frontend: runs-on: ubuntu-latest defaults: @@ -64,7 +27,7 @@ jobs: run: npm ci - name: Test - run: npm test + run: npm test -- --reporter=verbose - name: Build run: npm run build @@ -72,7 +35,7 @@ jobs: - name: Verify bundle run: | ls -la dist/assets/ - grep -c "vibarr\\|Library\\|TorrentTable" dist/assets/index-*.js + grep -c "Icarus\\|Library\\|TorrentTable" dist/assets/index-*.js backend: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index a6d10c1..0b9d4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -28,14 +28,12 @@ frontend/dist/ *.sublime-project *.sublime-workspace .DS_Store -._* Thumbs.db # ─── Runtime data (mounted volumes) ────────────────────────────────────────── # These are bind-mounted at runtime; checked in only as examples activity-log.json bandwidth-lifetime.json -installer-state.json # ─── Logs ──────────────────────────────────────────────────────────────────── logs/ @@ -46,6 +44,7 @@ npm-debug.log* .claude/ .codebase-memory/ CLAUDE.md +CHANGELOG.md DOWNLOAD_FLOW.md docs/ *.prefix-* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1649033..9b0084e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Contributing to vibarr +# Contributing to arr-dashboard Thank you for considering contributing! This document outlines the guidelines. diff --git a/README.md b/README.md index 13cdb93..416b604 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,198 @@ -# vibarr +# arr-dashboard -CEELO's Note: +> Unified web dashboard for managing your self-hosted media stack — Sonarr, Radarr, Lidarr, Prowlarr, qBittorrent, and Soulseek. -Vibarr is a vibeslopped system designed to make media curation effortless. I am deeply interested in preserving media and true ownership over your media in it's purest format. I got tired of the headaches that came from manually trying to install specific episodes or whole collections of TV shows, and having to jump through different websites to get it to work. Vibarr brings TV, Movies and Music all together in one platform. This way, you can effortlessly get true Lossless files accross all your content types and can even help Seed and spread the joy. + -This project has been authored by Claude and Codex throughout its entire journey. It started off as a personal project, that I eventually understood I can share to anyone else interested. I was not aware of the development of other projects such as Seerr, or Buildarr-- so I guess they exist, but this is to be more "independent" of these. This is just my take from the ground up of how to automate these great tools. As of May 12th, 2026 this is an incredibly early public build, intended for my friends and anyone who might randomly come accross this. Enjoy and leave feedback for me to manually review! +## Overview -Final Note: -Vibarr is intended to be used as a first time install on your system, assuming no other *Arr and Servarr applications have been installed prior. It assumes the user has already configured their VPN and assumes all the responsibility behind it on the user. Personally, I combine this with both Tailscale & Mullvad to ensure secure, remote access from anywhere in the world. I will touch on that more in future documentation but for now, assume that Vibarr is strictly for downloading files and handles nothing else. +arr-dashboard brings together all the services in your \*arr media stack into a single, modern web interface. Instead of jumping between six different UIs to check download status, manage your library, or search for content, everything is accessible from one place. -## Vibeslopped Notes: -vibarr runs as two containers: +### Features -- `vibarr-backend` (Express API) on internal port `3000` -- `vibarr-frontend` (Nginx + Vite static assets) on host `DASHBOARD_PORT` +- **Unified library view** — Browse movies (Radarr), TV series (Sonarr), and music artists (Lidarr) side-by-side +- **Live download tracking** — Real-time pipeline showing searches → grabs → imports across all \*arr services + qBittorrent + Soulseek +- **Manual search** — Browse releases sorted by a smart quality/seeders composite score, with one-click grab +- **Activity log** — Full audit trail of every add, delete, search, and download +- **Bandwidth monitor** — Live download/upload speeds with lifetime tracking (survives restarts) +- **Docker container monitoring** — Service health strip with direct links to each service +- **Light/dark mode** — System-aware theme with CSS custom properties, persists via localStorage +- **Mobile responsive** — Bottom nav, collapsible sidebar, touch-optimized controls +- **Accessibility** — ARIA labels, keyboard navigation, focus-visible rings, screen-reader-friendly -`docker-compose.yml` mounts the backend `docker.sock` for container management and proxies `/api/*` from the frontend to the backend. +### Services integrated -## Canonical repository +| Service | Purpose | +|---|---| +| **Radarr** | Movie library management | +| **Sonarr** | TV series library management | +| **Lidarr** | Music library management | +| **Prowlarr** | Indexer management (via \*arr proxies) | +| **qBittorrent** | Torrent client (downloads/seeding) | +| **Soulseek** (SLSKD) | Peer-to-peer music downloads | +| **Jellyfin** | Media server (status monitoring) | +| **Bazarr** | Subtitle management | +| **Docker** | Container health monitoring | -The canonical public repository for this project is: +## Quick start + +### Prerequisites + +- Docker and Docker Compose +- Node.js 22+ (for development) +- Running instances of Radarr, Sonarr, Lidarr, qBittorrent, and/or SLSKD + +### Configuration + +Copy the environment template and fill in your API keys: ```bash -https://github.com/ceelo510/vibarr.git +cp .env.example .env ``` -If you publish your own private fork or mirror, replace the GitHub owner in the clone commands below with your own repository path. +Edit `.env` with your service hosts and API keys: -## Quick start +```env +# Required +RADARR_API_KEY=your_radarr_api_key +SONARR_API_KEY=your_sonarr_api_key +LIDARR_API_KEY=your_lidarr_api_key + +# Optional (set host if not using Docker networking) +RADARR_HOST=http://radarr:7878 +SONARR_HOST=http://sonarr:8989 +LIDARR_HOST=http://lidarr:8686 +QBITTORRENT_HOST=http://qbittorrent:8080 +SLSKD_HOST=http://slskd:5030 + +# Required for torrent support +QBITTORRENT_USER=admin +QBITTORRENT_PASS=adminadmin + +# Required for Soulseek support +SLSKD_API_KEY=your_slskd_api_key +``` + +### Docker deployment + +```bash +docker compose build --no-cache +docker compose up -d +``` + +The dashboard will be available at `http://localhost:8888`. + +### Development ```bash -git clone https://github.com/ceelo510/vibarr.git -cd vibarr -./install.sh +# Backend +cd backend +npm install +npm run dev + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ nginx (port 8888) │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Frontend (Vite + React + Tailwind) │ │ +│ │ └─ dist/ served as static files │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ proxy /api/* │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ Backend (Express, port 3000) │ │ +│ │ └─ server.js → src/ modules │ │ +│ │ └─ Routes: system, qbit, arr, pipeline, │ │ +│ │ search, lookup, add, delete, │ │ +│ │ activity, meta, slskd │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────┬──────┬──────┼──────┬──────┬──────┐ │ +│ │Radarr│Sonarr│Lidarr│qBit │SLSKD │Docker│ │ +│ └──────┴──────┴──────┴──────┴──────┴──────┘ │ +└─────────────────────────────────────────────────────┘ ``` -`install.sh` is interactive on first run. Do not pipe it to `bash`. If you only downloaded the script, run it from a local interactive shell and let it clone the repo for you. +Server startup (`backend/src/middleware.js`) registers graceful shutdown handlers so intervals are cleaned up and state is persisted on SIGTERM/SIGINT. + +## Project structure -On Ubuntu/Debian-like systems, `install.sh` can now install Docker Engine and the Docker Compose plugin for you with `sudo` when Docker is missing or the daemon is unreachable. +``` +arr-dashboard/ +├── backend/ +│ ├── src/ +│ │ ├── config.js # Environment variables & constants +│ │ ├── state.js # In-memory state (pipeline, caches, activity) +│ │ ├── utils.js # Shared utilities (fetch helpers, normalization) +│ │ ├── middleware.js # Rate limiting, body limits, graceful shutdown +│ │ └── routes/ # Route modules (extracted from server.js) +│ ├── server.js # Express entry point +│ ├── Dockerfile +│ ├── package.json +│ └── .dockerignore +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Root component with polling & layout +│ │ ├── Library.jsx # Library browser (movies, series, music) +│ │ ├── SidePanel.jsx # Right sidebar (bandwidth, storage, downloads) +│ │ ├── TorrentTable.jsx # qBittorrent torrent list +│ │ ├── PipelineCard.jsx # Download pipeline card +│ │ ├── ManualSearchModal.jsx # Manual torrent search modal +│ │ ├── ActivityLog.jsx # SLSKD download cards +│ │ ├── api.js # Fetch wrapper (dedup, backoff, abort) +│ │ ├── constants.js # Design tokens, service config, breakpoints +│ │ ├── utils.js # Formatting & utility functions +│ │ ├── App.css # Global styles + CSS variables +│ │ └── index.css # Theme variables (dark/light) +│ ├── package.json +│ ├── vite.config.js +│ └── tailwind.config.js +├── docker-compose.yml +├── .env.example +├── .gitignore +├── LICENSE +└── README.md +``` -## Setup modes +## Security -- `INSTALLER_ENABLED=true` is the default clean-VM path. Leave `RADARR_API_KEY`, `SONARR_API_KEY`, `LIDARR_API_KEY`, and `SLSKD_API_KEY` blank, start the stack, open the dashboard root URL, then finish setup from the in-app Settings view. -- `INSTALLER_ENABLED=false` is the manual path. Set the Arr API keys in `.env` before startup and treat the dashboard as a client for an already-running stack. +- **API keys**: Stored in `.env`, mounted into the container, never baked into images. API calls to \*arr services use `X-Api-Key` headers, not query strings +- **Rate limiting**: 300 requests/minute per IP +- **Body limits**: 1MB maximum request body +- **Docker socket**: Only the backend container has socket access; frontend is served through nginx -## Compose files +## Development -- `docker-compose.yml` is the portable default for local installs and clean VMs. -- `docker-compose.production-host.yml` is the production-host override. It is no longer auto-loaded. -- On the production host, use both files explicitly: +### Testing ```bash -docker compose -f docker-compose.yml -f docker-compose.production-host.yml up -d +# Frontend tests +cd frontend +npx vitest run + +# Backend tests (when available) +cd backend +npm test ``` -## First run on a clean VM +### Code style -- On Ubuntu/Debian-like systems, `install.sh` offers a one-stop Docker bootstrap path if Docker is missing or broken and `sudo` is available. -- Compose creates or reuses the named bridge network from `ARR_NETWORK_NAME` automatically. There is no external-network precreate step anymore. -- `install.sh` seeds `backend/activity-log.json`, `backend/bandwidth-lifetime.json`, and `backend/installer-state.json` with valid JSON if they are missing or empty. -- The backend stays internal-only on port `3000`; the public entry point is the frontend on `DASHBOARD_PORT` (default `8888`). -- The setup UI is the dashboard root URL. There is no standalone frontend `/setup` route. -- The in-app installer selects Radarr, Sonarr, Lidarr, Prowlarr, qBittorrent, and SLSKD by default on clean VMs. -- Prowlarr is installed and linked by default, but public indexers are not auto-created. Add your preferred indexers in Prowlarr after setup; the production host currently uses BitSearch, LimeTorrents, Nyaa.si, TorrentGalaxyClone, and YTS. -- If the installer had to add your user to the `docker` group, it may keep using `sudo docker compose` in the current shell until you re-login or run `newgrp docker`. +- ES modules throughout (`import`/`export`) +- React functional components with hooks +- Tailwind CSS for styling (with inline style fallbacks) +- CSS custom properties for theming -## Logs and state +## License -- Backend and frontend logs: `docker compose logs -f backend frontend` -- Published dashboard URL: `docker compose port frontend 80` -- Installer state: `backend/installer-state.json` -- Activity log persistence: `backend/activity-log.json` -- Bandwidth lifetime persistence: `backend/bandwidth-lifetime.json` -- Nginx setup/install request logs: `docker compose exec frontend tail -f /var/log/nginx/access.log /var/log/nginx/error.log` +MIT — see [LICENSE](LICENSE). -## Endpoint notes +## Contributing -- Dashboard: `http://localhost:${DASHBOARD_PORT}` by default -- Backend API: `http://localhost:${DASHBOARD_PORT}/api` -- Setup API: `http://localhost:${DASHBOARD_PORT}/api/setup/state` and `/api/setup/install` when onboarding is enabled +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/SETUP.md b/SETUP.md index 2acb330..196eb29 100644 --- a/SETUP.md +++ b/SETUP.md @@ -1,110 +1,266 @@ -# Setup Notes +# Setup Guide -## Canonical repository +> **One-command install:** `curl -fsSL https://raw.githubusercontent.com/anomalyco/arr-dashboard/main/install.sh | bash` -The public repository this guide refers to is: +## Prerequisites + +| Requirement | Minimum | Notes | +|---|---|---| +| Docker | 24+ | [Install Docker](https://docs.docker.com/engine/install/) | +| Docker Compose | 2.24+ | Included with Docker Desktop / Docker Engine | +| Git | 2.x | `apt install git` / `brew install git` | +| RAM | 512MB free | Backend + frontend containers | +| Disk | 500MB free | Docker images + build cache | + +### Optional + +| Service | Needed for | +|---|---| +| [Radarr](https://radarr.video/) | Movie library browsing, search, and download tracking | +| [Sonarr](https://sonarr.tv/) | TV series library browsing, search, and download tracking | +| [Lidarr](https://lidarr.audio/) | Music library browsing and download tracking | +| [qBittorrent](https://www.qbittorrent.org/) | Torrent download queue monitoring and management | +| [Prowlarr](https://prowlarr.com/) | Direct indexer search without waiting for *arr backlog | +| [SLSKD](https://github.com/slskd/slskd) | Soulseek download queue monitoring | +| [Media-sorter](https://github.com/anomalyco/media-sorter) | Sort history and pipeline tracking (separate install) | +| Tailscale | Auto-detection of tailnet IP in sidebar | + +These can be added later — the dashboard will gracefully degrade and show only the services you have configured. + +## Quick Start + +### 1. Automated install ```bash -https://github.com/ceelo510/vibarr.git +curl -fsSL https://raw.githubusercontent.com/anomalyco/arr-dashboard/main/install.sh | bash ``` -If you are using your own private fork or mirror, swap the GitHub owner in the clone commands you share with collaborators. The install flow stays the same. +The script will: +1. Clone the repository to `~/arr-dashboard` +2. Prompt for API keys (Radarr, Sonarr, Lidarr are required; others optional) +3. Create the `arr-network` Docker network +4. Build and start both containers +5. Wait for the backend health check to pass + +### 2. Manual install + +```bash +git clone https://github.com/anomalyco/arr-dashboard.git ~/arr-dashboard +cd ~/arr-dashboard +cp .env.example .env +``` + +Edit `.env` to add your API keys: + +```ini +# Required +RADARR_API_KEY=your_radarr_key_here +SONARR_API_KEY=your_sonarr_key_here +LIDARR_API_KEY=your_lidarr_key_here + +# Optional — default values work for Docker-hosted services +# QBITTORRENT_USER=admin +# QBITTORRENT_PASS=adminadmin +# SLSKD_API_KEY=your_slskd_key_here +# PROWLARR_API_KEY=your_prowlarr_key_here +``` -## Quick start +Then start: ```bash -git clone https://github.com/ceelo510/vibarr.git -cd vibarr -./install.sh +touch backend/activity-log.json backend/bandwidth-lifetime.json +docker network create arr-network 2>/dev/null || true +docker compose build --no-cache +docker compose up -d ``` -## Supported bootstrap paths +### 3. Verify + +Open [http://localhost:8888](http://localhost:8888) in your browser. You should see: -### 1. Web onboarding-first +- The dashboard with a sidebar showing system stats and download queues +- TV series, movies, and music cards populating from your *arr instances +- Activity/pipeline feed if enabled -Use this on a clean VM. +--- -- Leave `INSTALLER_ENABLED=true` in `.env`. -- Leave `RADARR_API_KEY`, `SONARR_API_KEY`, and `LIDARR_API_KEY` blank. -- Run `./install.sh` from an interactive shell. -- If Docker Engine or the Docker Compose plugin is missing on Ubuntu/Debian-like systems, let the installer add them with `sudo`. -- Open the dashboard root URL after startup and continue from the in-app Settings view. -- The in-app installer selects Radarr, Sonarr, Lidarr, Prowlarr, qBittorrent, and SLSKD by default. +## Configuration Reference -Important: the setup UI is part of the main frontend app. There is no dedicated frontend `/setup` page. +### `.env` file -### 2. Manual env-first +All environment variables and their defaults: -Use this if the media stack already exists and you only need the dashboard. +| Variable | Required | Default | Description | +|---|---|---|---| +| `RADARR_API_KEY` | Yes | — | Radarr API key (Settings → General) | +| `SONARR_API_KEY` | Yes | — | Sonarr API key | +| `LIDARR_API_KEY` | Yes | — | Lidarr API key | +| `RADARR_HOST` | No | `http://radarr:7878` | Radarr service URL | +| `SONARR_HOST` | No | `http://sonarr:8989` | Sonarr service URL | +| `LIDARR_HOST` | No | `http://lidarr:8686` | Lidarr service URL | +| `QBITTORRENT_HOST` | No | `http://qbittorrent:8080` | qBittorrent WebUI URL | +| `QBITTORRENT_USER` | No | `admin` | qBittorrent username | +| `QBITTORRENT_PASS` | No | `adminadmin` | qBittorrent password | +| `SLSKD_HOST` | No | `http://slskd:5030` | SLSKD API URL | +| `SLSKD_API_KEY` | No | — | SLSKD API key | +| `PROWLARR_HOST` | No | `http://prowlarr:9696` | Prowlarr API URL | +| `PROWLARR_API_KEY` | No | — | Prowlarr API key | +| `MEDIA_SORTER_HOST` | No | — | Media-sorter API URL | +| `PORT` | No | `3000` | Backend port | +| `NODE_ENV` | No | `production` | Node environment | -- Set `INSTALLER_ENABLED=false`. -- Fill `RADARR_API_KEY`, `SONARR_API_KEY`, and `LIDARR_API_KEY` in `.env`. -- Start the stack with `docker compose up -d`. +> **Host defaults** assume all services are on the same Docker network (`arr-network`). +> Change to `http://hostname:port` if services run outside Docker or on different hosts. -## Compose defaults +### Docker network -- `docker-compose.yml` is now portable by itself. -- `ARR_NETWORK_NAME` defaults to `arr-network`, and Compose creates or reuses that named bridge network automatically. -- `DASHBOARD_PORT` defaults to `8888`. -- `INSTALLER_STATE_HOST_PATH` defaults to `./backend/installer-state.json`. -- `INSTALLER_STATE_PATH` defaults to `/app/installer-state.json`. +All containers must share the `arr-network` Docker network. If your *arr services are in a different Docker Compose stack, ensure they all connect to the same network: -## Production host overrides +```yaml +# Add this to your *arr service's compose file +networks: + default: + external: + name: arr-network +``` -The old `docker-compose.override.yml` auto-load path was removed because it hard-coded the production host and broke generic installs. +--- -Production-specific mounts and fixed LAN binds now live in: +## Architecture -```text -docker-compose.production-host.yml +``` + ┌──────────────┐ + │ Browser │ + │ :8888 (:80) │ + └──────┬───────┘ + │ + nginx proxy + /api/ → backend:3000 + /* → static files + │ + ┌────────────────┼────────────────┐ + │ │ │ + ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ Backend │ │ Docker │ │ Tailscale │ + │ Node.js │ │ socket │ │ socket │ + │ :3000 │ │ (read-only)│ │ (optional)│ + └────┬────┘ └───────────┘ └───────────┘ + │ + ┌─────────┼──────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + Radarr Sonarr Lidarr qBittorrent SLSKD + :7878 :8989 :8686 :8080 :5030 ``` -Use it explicitly on the production host: +--- -```bash -docker compose -f docker-compose.yml -f docker-compose.production-host.yml up -d -docker compose -f docker-compose.yml -f docker-compose.production-host.yml build --no-cache frontend -docker compose -f docker-compose.yml -f docker-compose.production-host.yml up -d --force-recreate frontend +## Advanced + +### Custom port + +Change the dashboard port by editing `ports` in `docker-compose.yml`: + +```yaml +ports: + - "9090:80" # dashboard at http://localhost:9090 ``` -## Runtime JSON files +### Reverse proxy (Caddy / Nginx) -`install.sh` initializes these files with valid JSON when they are missing or empty: +```nginx +# Caddyfile +dashboard.example.com { + reverse_proxy localhost:8888 +} +``` + +### Persistent data +Activity logs and bandwidth stats are persisted in: - `backend/activity-log.json` - `backend/bandwidth-lifetime.json` -- `backend/installer-state.json` by default, or whatever `INSTALLER_STATE_HOST_PATH` points to -## Logs and setup triage +These are bind-mounted from the host. If you delete or recreate them, the corresponding stats will reset. + +### Disk usage stats -Start here when onboarding is stuck, setup requests time out, or logging appears empty: +The backend mounts the host root (`/:/hostfs:ro`) and `/docker` to show disk usage. These are optional — the dashboard works without them (just hides the disk widget). + +```yaml +volumes: + - /:/hostfs:ro # optional — df stats + - /docker:/hostdocker:ro # optional — du by directory +``` + +--- + +## Troubleshooting + +### Backend won't start ```bash -docker compose logs -f backend frontend -docker compose port frontend 80 -docker compose exec frontend tail -f /var/log/nginx/access.log /var/log/nginx/error.log +cd ~/arr-dashboard +docker compose logs backend ``` -Then inspect the runtime state files: +Common issues: +- **API key missing**: Verify `.env` has `RADARR_API_KEY`, `SONARR_API_KEY`, `LIDARR_API_KEY` set +- **Docker socket**: The backend needs read-only access to `/var/run/docker.sock`. Ensure it's available on your system +- **Port conflict**: Port 3000 or 8888 already in use → change `ports` in compose -- `backend/installer-state.json` -- `backend/activity-log.json` -- `backend/bandwidth-lifetime.json` +### Frontend shows blank page + +```bash +# Check nginx is serving +curl -s -o /dev/null -w '%{http_code}' http://localhost:8888/ +# Should return 200 -Expected first-run behavior on a clean VM: +# Check API proxy +curl -s http://localhost:8888/api/health +# Should return {"status":"ok",...} +``` -- on Ubuntu/Debian-like systems, `install.sh` can install Docker Engine and the Compose plugin automatically if `sudo` is available -- the dashboard root URL loads even if no library service is configured yet -- the app pushes you toward the Settings view when setup is still required -- `/api/setup/state` stays available while `INSTALLER_ENABLED=true` -- if the installer had to add your user to the `docker` group, follow-up commands in the same shell may need `sudo docker compose ...` until you re-login or run `newgrp docker` +If both work, clear your browser cache and hard-reload (Cmd+Shift+R). -## Docker socket access +### "Service not configured" in UI -The backend uses Docker APIs during `/api/setup/*` operations, so Compose mounts: +The dashboard hides features for missing services. To use a service: +1. Run the service (e.g., Sonarr) and make sure it's on the `arr-network` +2. Add its API key to `.env` +3. Restart: `docker compose restart backend` -```yaml -/var/run/docker.sock:/var/run/docker.sock +### "No space left" on build + +```bash +docker builder prune -f +docker system prune -f ``` -with write access. This is required for container creation and reconciliation during bootstrap. +--- + +## Updating + +```bash +cd ~/arr-dashboard +git pull +docker compose build --no-cache +docker compose up -d --force-recreate +``` + +--- + +## Uninstall + +```bash +cd ~/arr-dashboard +docker compose down -v +docker rmi arr-dashboard-backend arr-dashboard-frontend +rm -rf ~/arr-dashboard +``` + +--- + +## Getting help + +- Open an issue: [github.com/anomalyco/arr-dashboard/issues](https://github.com/anomalyco/arr-dashboard/issues) +- Check the [README](https://github.com/anomalyco/arr-dashboard#readme) for feature overview diff --git a/backend/Dockerfile b/backend/Dockerfile index c3c52f7..8ac94a7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,17 +5,7 @@ WORKDIR /app COPY package*.json ./ -RUN set -eu; \ - attempt=1; \ - while true; do \ - npm ci --omit=dev && break; \ - if [ "$attempt" -ge 5 ]; then \ - exit 1; \ - fi; \ - attempt=$((attempt + 1)); \ - npm cache clean --force; \ - sleep 5; \ - done +RUN npm install --omit=dev FROM node:22-alpine diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..f32510d --- /dev/null +++ b/backend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + { + ignores: ['node_modules/', 'coverage/'], + }, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, + rules: { + 'no-unused-vars': 'warn', + 'no-console': 'off', + 'no-undef': 'off', + 'no-empty': 'warn', + 'no-useless-escape': 'warn', + eqeqeq: 'warn', + curly: 'off', + }, + }, +]; diff --git a/backend/package-lock.json b/backend/package-lock.json index a6e4a31..08d136a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,18 +1,24 @@ { - "name": "vibarr-backend", + "name": "arr-dashboard-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "vibarr-backend", + "name": "arr-dashboard-backend", "version": "1.0.0", "dependencies": { "cors": "^2.8.5", "dockerode": "^4.0.2", "express": "^4.18.2", - "node-fetch": "^3.3.2", - "xml2js": "^0.6.2" + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "@eslint/js": "^9.26.0", + "eslint": "^9.26.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "prettier": "^3.5.3" }, "engines": { "node": ">=22.0.0" @@ -24,6 +30,150 @@ "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", "license": "Apache-2.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/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/@grpc/grpc-js": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", @@ -73,6 +223,72 @@ "node": ">=6" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.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/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -147,13 +363,27 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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": "25.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", - "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.21.0" } }, "node_modules/accepts": { @@ -169,6 +399,46 @@ "node": ">= 0.6" } }, + "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.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -193,12 +463,157 @@ "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/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -208,6 +623,39 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -287,19 +735,15 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/buffer": { @@ -344,6 +788,25 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -373,6 +836,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/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/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -411,6 +901,13 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "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/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -478,15 +975,84 @@ "node": ">=10.0.0" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "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", - "engines": { + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { "node": ">= 12" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -504,6 +1070,49 @@ } } }, + "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/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -556,6 +1165,19 @@ "node": ">= 8.0" } }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -600,6 +1222,75 @@ "once": "^1.4.0" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -618,6 +1309,34 @@ "node": ">= 0.4" } }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -630,6 +1349,53 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -645,6 +1411,219 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "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": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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-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/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -655,14 +1634,14 @@ } }, "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "~1.20.3", + "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", @@ -681,7 +1660,7 @@ "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "~6.14.0", + "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -715,6 +1694,27 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "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/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -738,6 +1738,19 @@ "node": "^12.20 || >= 14.13" } }, + "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/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -771,33 +1784,87 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "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": { - "fetch-blob": "^3.1.2" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=12.20.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "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": ">= 0.6" + "node": ">=16" } }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "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/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", - "engines": { + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { "node": ">= 0.6" } }, @@ -816,6 +1883,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -862,6 +1970,67 @@ "node": ">= 0.4" } }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": "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/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -874,6 +2043,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -886,6 +2107,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -950,12 +2187,64 @@ ], "license": "BSD-3-Clause" }, + "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/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -965,6 +2254,167 @@ "node": ">= 0.10" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -974,18 +2424,388 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "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/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "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/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.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "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/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1055,6 +2875,19 @@ "node": ">= 0.6" } }, + "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/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -1074,6 +2907,13 @@ "license": "MIT", "optional": true }, + "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/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1103,6 +2943,25 @@ "node": ">=10.5.0" } }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", @@ -1142,6 +3001,91 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1163,6 +3107,87 @@ "wrappy": "1" } }, + "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/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1172,16 +3197,91 @@ "node": ">= 0.8" } }, + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.7.tgz", + "integrity": "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -1225,10 +3325,20 @@ "once": "^1.3.1" } }, + "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/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1264,6 +3374,13 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -1278,6 +3395,50 @@ "node": ">= 6" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1287,6 +3448,60 @@ "node": ">=0.10.0" } }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1307,19 +3522,55 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" + "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/send": { @@ -1367,13 +3618,62 @@ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.4" } }, "node_modules/setprototypeof": { @@ -1382,6 +3682,29 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "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/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -1486,6 +3809,20 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -1509,6 +3846,104 @@ "node": ">=8" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -1521,6 +3956,45 @@ "node": ">=8" } }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1564,6 +4038,19 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "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/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1577,10 +4064,107 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", "license": "MIT" }, "node_modules/unpipe": { @@ -1592,6 +4176,16 @@ "node": ">= 0.8" } }, + "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/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1639,6 +4233,121 @@ "node": ">= 8" } }, + "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/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1662,28 +4371,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1719,6 +4406,19 @@ "engines": { "node": ">=12" } + }, + "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" + } } } } diff --git a/backend/package.json b/backend/package.json index 9b6abca..fcbb3ab 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "vibarr-backend", + "name": "arr-dashboard-backend", "version": "1.0.0", "description": "Backend API for ARR Stack Dashboard", "main": "server.js", @@ -7,17 +7,23 @@ "scripts": { "start": "node server.js", "dev": "node --watch server.js", - "test": "node --test test/*.test.js && node --check server.js && for f in src/*.js src/routes/*.js; do [ -f \"$f\" ] && node --check \"$f\"; done", + "test": "node --test test/*.test.js && node --check server.js && for f in src/*.js src/routes/*.js src/library/*.js src/library/helpers/*.js src/library/routes/*.js src/pipeline/*.js; do node --check \"$f\"; done", "lint": "eslint . --ext .js", "lint:fix": "eslint . --ext .js --fix", "format": "prettier --write \"**/*.js\"" }, "dependencies": { + "express": "^4.18.2", "cors": "^2.8.5", "dockerode": "^4.0.2", - "express": "^4.18.2", - "node-fetch": "^3.3.2", - "xml2js": "^0.6.2" + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "@eslint/js": "^9.26.0", + "eslint": "^9.26.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "prettier": "^3.5.3" }, "engines": { "node": ">=22.0.0" diff --git a/backend/server.js b/backend/server.js index e5413c4..907b60c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,8 +7,7 @@ import { errorHandler, setupGracefulShutdown, } from './src/middleware.js'; -import { loadBwLifetime, loadPersistedActivityLog, logServerEvent } from './src/state.js'; -import { restoreQueueAlertsFromActivityLog } from './src/routes/pipeline.js'; +import { loadBwLifetime } from './src/state.js'; import mountRoutes from './src/routes/index.js'; const app = express(); @@ -23,14 +22,7 @@ noCacheMiddleware(app); app.use(rateLimitMiddleware); // Restore persisted counters before requests begin mutating in-memory state. -const bandwidthRestore = loadBwLifetime(); -const activityRestore = loadPersistedActivityLog(); -restoreQueueAlertsFromActivityLog(); -const restoreFailed = [bandwidthRestore, activityRestore].some((restore) => restore?.status === 'error' || restore?.status === 'invalid'); -logServerEvent(restoreFailed ? 'error' : 'info', 'backend.boot.state_restore', { - bandwidth: bandwidthRestore, - activityLog: activityRestore, -}); +loadBwLifetime(); app.use('/api', mountRoutes); @@ -38,11 +30,8 @@ app.use('/api', mountRoutes); app.use(errorHandler); const server = app.listen(PORT, '0.0.0.0', () => { - logServerEvent('info', 'backend.boot.ready', { - port: Number(PORT), - environment: process.env.NODE_ENV || 'development', - trustProxy: app.get('trust proxy'), - }); + console.log('ARR Dashboard Backend running on port ' + PORT); + console.log('Environment: ' + (process.env.NODE_ENV || 'development')); }); setupGracefulShutdown(server); diff --git a/backend/src/config.js b/backend/src/config.js index 296999a..973cf19 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,90 +1,3 @@ -import { existsSync, readFileSync } from 'fs'; - -const INSTALLER_STATE_PATH = process.env.INSTALLER_STATE_PATH || '/app/installer-state.json'; -const MANAGED_ROOT_FOLDERS = Object.freeze({ - radarr: '/data/movies', - sonarr: '/data/tv', - lidarr: '/data/music', -}); -const LEGACY_ROOT_FOLDERS = Object.freeze({ - radarr: '/movies', - sonarr: '/tv', - lidarr: '/music', -}); -const ROOT_FOLDER_ENV_KEYS = Object.freeze({ - radarr: 'RADARR_ROOT_FOLDER', - sonarr: 'SONARR_ROOT_FOLDER', - lidarr: 'LIDARR_ROOT_FOLDER', -}); - -function parseBoolean(value, defaultValue = false) { - if (typeof value === 'boolean') return value; - if (typeof value !== 'string') return defaultValue; - const normalized = value.trim().toLowerCase(); - return ['1', 'true', 'yes', 'on'].includes(normalized); -} - -function loadInstallerStateSnapshot() { - try { - if (!existsSync(INSTALLER_STATE_PATH)) return {}; - const parsed = JSON.parse(readFileSync(INSTALLER_STATE_PATH, 'utf-8')); - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch (error) { - console.error('Failed to load installer state snapshot:', error.message); - return {}; - } -} - -function loadInstallerOverrides(stateSnapshot) { - return stateSnapshot?.serviceConfig && typeof stateSnapshot.serviceConfig === 'object' - ? stateSnapshot.serviceConfig - : {}; -} - -function normalizeRootFolderService(serviceName) { - switch ((serviceName || '').toString().trim().toLowerCase()) { - case 'movie': - case 'movies': - case 'radarr': - return 'radarr'; - case 'series': - case 'tv': - case 'sonarr': - return 'sonarr'; - case 'music': - case 'artist': - case 'artists': - case 'lidarr': - return 'lidarr'; - default: - return null; - } -} - -function readRootFolderOverride(stateSnapshot, serviceName) { - const normalized = normalizeRootFolderService(serviceName); - if (!normalized) return null; - - const envKey = ROOT_FOLDER_ENV_KEYS[normalized]; - const candidates = [ - stateSnapshot?.rootFolders?.[normalized], - stateSnapshot?.setup?.rootFolders?.[normalized], - stateSnapshot?.serviceConfig?.[envKey], - process.env[envKey], - ]; - - for (const value of candidates) { - if (typeof value === 'string' && value.trim() !== '') { - return value.trim(); - } - } - - return null; -} - -const INSTALLER_STATE_SNAPSHOT = loadInstallerStateSnapshot(); -const INSTALLER_OVERRIDES = loadInstallerOverrides(INSTALLER_STATE_SNAPSHOT); - export const CONFIG = { PORT: process.env.PORT || 3000, NODE_ENV: process.env.NODE_ENV || 'development', @@ -92,24 +5,22 @@ export const CONFIG = { MAX_PEER_RETRIES: 3, DOCKER_SOCKET: '/var/run/docker.sock', TAILSCALE_SOCKET: '/var/run/tailscale/tailscaled.sock', - INSTALLER_STATE_PATH, - INSTALLER_ENABLED: parseBoolean(process.env.INSTALLER_ENABLED || process.env.ENABLE_INSTALLER, false), - - RADARR_HOST: INSTALLER_OVERRIDES.RADARR_HOST || process.env.RADARR_HOST || 'http://radarr:7878', - RADARR_API_KEY: INSTALLER_OVERRIDES.RADARR_API_KEY || process.env.RADARR_API_KEY || '', - SONARR_HOST: INSTALLER_OVERRIDES.SONARR_HOST || process.env.SONARR_HOST || 'http://sonarr:8989', - SONARR_API_KEY: INSTALLER_OVERRIDES.SONARR_API_KEY || process.env.SONARR_API_KEY || '', - LIDARR_HOST: INSTALLER_OVERRIDES.LIDARR_HOST || process.env.LIDARR_HOST || 'http://lidarr:8686', - LIDARR_API_KEY: INSTALLER_OVERRIDES.LIDARR_API_KEY || process.env.LIDARR_API_KEY || '', - SLSKD_HOST: INSTALLER_OVERRIDES.SLSKD_HOST || process.env.SLSKD_HOST || 'http://slskd:5030', - SLSKD_API_KEY: INSTALLER_OVERRIDES.SLSKD_API_KEY || process.env.SLSKD_API_KEY || '', - QBITTORRENT_HOST: INSTALLER_OVERRIDES.QBITTORRENT_HOST || process.env.QBITTORRENT_HOST || 'http://qbittorrent:8080', - QBITTORRENT_USER: INSTALLER_OVERRIDES.QBITTORRENT_USER || process.env.QBITTORRENT_USER || '', - QBITTORRENT_PASS: INSTALLER_OVERRIDES.QBITTORRENT_PASS || process.env.QBITTORRENT_PASS || '', - - PROWLARR_HOST: INSTALLER_OVERRIDES.PROWLARR_HOST || process.env.PROWLARR_HOST || 'http://prowlarr:9696', - PROWLARR_API_KEY: INSTALLER_OVERRIDES.PROWLARR_API_KEY || process.env.PROWLARR_API_KEY || '', + RADARR_HOST: process.env.RADARR_HOST || 'http://radarr:7878', + RADARR_API_KEY: process.env.RADARR_API_KEY || '', + SONARR_HOST: process.env.SONARR_HOST || 'http://sonarr:8989', + SONARR_API_KEY: process.env.SONARR_API_KEY || '', + LIDARR_HOST: process.env.LIDARR_HOST || 'http://lidarr:8686', + LIDARR_API_KEY: process.env.LIDARR_API_KEY || '', + SLSKD_HOST: process.env.SLSKD_HOST || 'http://slskd:5030', + SLSKD_API_KEY: process.env.SLSKD_API_KEY || '', + + QBITTORRENT_HOST: process.env.QBITTORRENT_HOST || 'http://qbittorrent:8080', + QBITTORRENT_USER: process.env.QBITTORRENT_USER || 'admin', + QBITTORRENT_PASS: process.env.QBITTORRENT_PASS || 'adminadmin', + + PROWLARR_HOST: process.env.PROWLARR_HOST || 'http://prowlarr:9696', + PROWLARR_API_KEY: process.env.PROWLARR_API_KEY || '', MEDIA_SORTER_HOST: process.env.MEDIA_SORTER_HOST || '', ACTIVITY_LOG_PATH: '/app/activity-log.json', @@ -136,20 +47,3 @@ export const CONSTANTS = { HOST_DOCKER_DIR: '/hostdocker', FALLBACK_QUALITY_PROFILE: 1, }; - -export function getDefaultRootFolder(serviceName) { - const normalized = normalizeRootFolderService(serviceName); - if (!normalized) return null; - - const override = readRootFolderOverride(INSTALLER_STATE_SNAPSHOT, normalized); - if (override) return override; - - const installerManaged = INSTALLER_STATE_SNAPSHOT?.managed === true; - const installerEnabledService = INSTALLER_STATE_SNAPSHOT?.services?.[normalized] === true; - - if (installerManaged || installerEnabledService) { - return MANAGED_ROOT_FOLDERS[normalized]; - } - - return LEGACY_ROOT_FOLDERS[normalized]; -} diff --git a/backend/src/errors.js b/backend/src/errors.js index fe975f0..2d9ca0d 100644 --- a/backend/src/errors.js +++ b/backend/src/errors.js @@ -1,13 +1,10 @@ export class AppError extends Error { - constructor(status, message, { code, publicMessage, details, cause, retryable, logDetails } = {}) { + constructor(status, message, { code, details, cause } = {}) { super(message); this.name = 'AppError'; this.status = status; this.code = code || 'UNKNOWN'; - this.publicMessage = publicMessage || message; this.details = details || null; - this.retryable = Boolean(retryable); - this.logDetails = logDetails || null; if (cause) this.cause = cause; } } @@ -24,52 +21,6 @@ export class ValidationError extends AppError { } } -export class InstallerDisabledError extends AppError { - constructor() { - super(403, 'Installer actions are disabled', { - code: 'INSTALLER_DISABLED', - publicMessage: 'Installer actions are disabled. Enable installer mode before retrying.', - }); - } -} - -export class InstallerConflictError extends AppError { - constructor(conflicts = []) { - super( - 409, - `Installer cannot take over existing unmanaged containers: ${conflicts.join(', ')}`, - { - code: 'INSTALLER_CONFLICT', - publicMessage: 'Setup cannot continue because one or more selected services already exist outside installer management.', - details: { conflicts }, - }, - ); - } -} - -export class InstallerValidationError extends AppError { - constructor(message, details = null) { - super(400, message, { - code: 'INSTALLER_VALIDATION_ERROR', - publicMessage: 'The setup request is invalid. Review the highlighted fields and retry.', - details, - }); - } -} - -export class InstallerExecutionError extends AppError { - constructor(message, { code = 'INSTALLER_FAILED', publicMessage, details, cause, retryable, logDetails } = {}) { - super(500, message, { - code, - publicMessage: publicMessage || 'Setup failed before the dashboard could finish configuring services.', - details, - cause, - retryable, - logDetails, - }); - } -} - export class ServiceError extends AppError { constructor(service, message, cause) { const status = cause?.status || 502; @@ -96,11 +47,9 @@ export function wrapRouter(router) { const originalPut = router.put; // Mutate verb helpers once so route modules can use async handlers without local wrappers. - const wrap = (method) => { + const wrap = method => { return function (...args) { - const wrappedArgs = args.map((arg) => - typeof arg === 'function' ? asyncHandler(arg) : arg, - ); + const wrappedArgs = args.map(arg => (typeof arg === 'function' ? asyncHandler(arg) : arg)); return method.call(this, ...wrappedArgs); }; }; diff --git a/backend/src/library.js b/backend/src/library.js index 79a1cbb..0d107e4 100644 --- a/backend/src/library.js +++ b/backend/src/library.js @@ -1,1475 +1 @@ -import { Router } from 'express'; -import { CONFIG, TIMING } from '../config.js'; -import { - fetchWithTimeout, pickImageUrl, pickArrImageUrl, - parseArrError, normalizeForMatch, -} from '../utils.js'; -import { - logActivity, updateLogEntry, addLogStep, - libraryCache, setLibraryCache, itunesPosterCache, - addPipelineItem, advancePipeline, registerInterval, -} from '../state.js'; -import { searchAndDownloadSlskd } from './slskd.js'; -import { execFileSync } from 'child_process'; -import { statSync } from 'fs'; - -const router = Router(); - -const { - RADARR_HOST, RADARR_API_KEY, - SONARR_HOST, SONARR_API_KEY, - LIDARR_HOST, LIDARR_API_KEY, - SLSKD_API_KEY, -} = CONFIG; - -// ─── Library Cache Refresh ────────────────────────────────────────────────── - -let libraryCacheRefreshInterval = null; - -export async function refreshLibraryCache() { - const tasks = []; - - if (SONARR_API_KEY) { - tasks.push((async () => { - try { - const series = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`, 10000); - libraryCache.series = series.map(s => ({ - id: s.id, tvdbId: s.tvdbId, title: s.title, sortTitle: s.sortTitle, - year: s.year, overview: s.overview, network: s.network, - genres: s.genres || [], ratings: s.ratings, - seasonCount: s.statistics?.seasonCount || s.seasonCount || 0, - totalEpisodeCount: s.statistics?.totalEpisodeCount || 0, - episodeFileCount: s.statistics?.episodeFileCount || 0, - sizeOnDisk: s.statistics?.sizeOnDisk || 0, - posterUrl: pickArrImageUrl(s.images, 'poster', 'sonarr'), - status: s.status, path: s.path, monitored: s.monitored, - })); - } catch (err) { console.error('Library cache - Sonarr:', err.message); } - })()); - } - - if (RADARR_API_KEY) { - tasks.push((async () => { - try { - const movies = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`, 10000); - libraryCache.movies = movies.map(m => ({ - id: m.id, tmdbId: m.tmdbId, title: m.title, sortTitle: m.sortTitle, - year: m.year, overview: m.overview, genres: m.genres || [], - ratings: m.ratings, runtime: m.runtime, - posterUrl: pickArrImageUrl(m.images, 'poster', 'radarr'), - hasFile: m.hasFile, monitored: m.monitored, status: m.status, - path: m.path, sizeOnDisk: m.sizeOnDisk || 0, - quality: m.movieFile?.quality?.quality?.name || null, - })); - } catch (err) { console.error('Library cache - Radarr:', err.message); } - })()); - } - - if (LIDARR_API_KEY) { - tasks.push((async () => { - try { - const artists = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist?apikey=${LIDARR_API_KEY}`, 10000); - const artistList = artists.map(a => ({ - id: a.id, foreignArtistId: a.foreignArtistId, - artistName: a.artistName, sortName: a.sortName, - overview: a.overview, genres: a.genres || [], - posterUrl: pickImageUrl(a.images, 'poster') || pickImageUrl(a.images, 'cover') || null, - monitored: a.monitored, status: a.status, path: a.path, - albumCount: a.statistics?.albumCount || 0, - trackFileCount: a.statistics?.trackFileCount || 0, - trackCount: a.statistics?.trackCount || 0, - sizeOnDisk: a.statistics?.sizeOnDisk || 0, - })); - const noPoster = artistList.filter(a => !a.posterUrl); - if (noPoster.length > 0) { - await Promise.allSettled(noPoster.map(async (a) => { - try { - const albums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${a.id}&apikey=${LIDARR_API_KEY}`, 10000); - for (const alb of albums) { - const cover = pickImageUrl(alb.images, 'cover'); - if (cover) { a.posterUrl = cover; break; } - } - } catch {} - })); - } - // Lidarr artist records often lack art; borrow the first album cover so library cards stay populated. - libraryCache.artists = artistList; - try { - const allAlbums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?apikey=${LIDARR_API_KEY}`, 15000); - const _dlByArtist = {}; - allAlbums.forEach(a => { - if ((a.statistics?.trackFileCount || 0) > 0) - _dlByArtist[a.artistId] = (_dlByArtist[a.artistId] || 0) + 1; - }); - artistList.forEach(a => { a.downloadedAlbumCount = _dlByArtist[a.id] || 0; }); - // Keep the lightweight library cache scoped to albums that already have files on disk. - libraryCache.albums = allAlbums - .filter(a => (a.statistics?.trackFileCount || 0) > 0) - .map(a => ({ - id: a.id, title: a.title, artistId: a.artistId, - coverUrl: pickImageUrl(a.images, 'cover') || null, - })); - } catch (e) { console.error('Library cache - Lidarr albums:', e.message); } - } catch (err) { console.error('Library cache - Lidarr:', err.message); } - })()); - } - - await Promise.allSettled(tasks); - libraryCache.lastRefresh = new Date().toISOString(); -} - -refreshLibraryCache(); -registerInterval(setInterval(refreshLibraryCache, 60000)); - -// ─── Routes ───────────────────────────────────────────────────────────────── - -router.get('/library/refresh', async (req, res) => { - await refreshLibraryCache(); - res.json({ ok: true, lastRefresh: libraryCache.lastRefresh }); -}); - -router.get('/library/search', (req, res) => { - const q = (req.query.q || '').toLowerCase().trim(); - const type = req.query.type || 'all'; - const results = { series: [], movies: [], artists: [] }; - - if (type === 'all' || type === 'series') { - results.series = libraryCache.series - .filter(s => !q || s.title.toLowerCase().includes(q)) - .sort((a, b) => a.sortTitle.localeCompare(b.sortTitle)) - .slice(0, 50); - } - if (type === 'all' || type === 'movie') { - results.movies = libraryCache.movies - .filter(m => !q || m.title.toLowerCase().includes(q)) - .sort((a, b) => a.sortTitle.localeCompare(b.sortTitle)) - .slice(0, 50); - } - if (type === 'all' || type === 'music') { - results.artists = libraryCache.artists - .filter(a => !q || a.artistName.toLowerCase().includes(q)) - .sort((a, b) => (a.sortName || a.artistName).localeCompare(b.sortName || b.artistName)) - .slice(0, 50); - } - res.json(results); -}); - -router.get('/library/series/:id/episodes', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - try { - const sid = encodeURIComponent(req.params.id); - const [episodes, episodeFiles] = await Promise.all([ - fetchWithTimeout(`${SONARR_HOST}/api/v3/episode?seriesId=${sid}&apikey=${SONARR_API_KEY}`, 10000), - fetchWithTimeout(`${SONARR_HOST}/api/v3/episodefile?seriesId=${sid}&apikey=${SONARR_API_KEY}`, 10000), - ]); - const fileMap = {}; - (Array.isArray(episodeFiles) ? episodeFiles : []).forEach(f => { fileMap[f.id] = f; }); - const seasons = {}; - episodes.forEach(ep => { - const sn = ep.seasonNumber; - if (!seasons[sn]) seasons[sn] = []; - const ef = ep.episodeFileId ? fileMap[ep.episodeFileId] : null; - const mi = ef?.mediaInfo || {}; - seasons[sn].push({ - id: ep.id, episodeNumber: ep.episodeNumber, title: ep.title, - airDate: ep.airDateUtc, hasFile: ep.hasFile, monitored: ep.monitored, - overview: ep.overview, - runtime: ep.runtime || null, - quality: ef?.quality?.quality?.name || null, - size: ef?.size || 0, - filePath: ef?.path || null, - relativePath: ef?.relativePath || null, - videoCodec: mi.videoCodec || null, - videoFps: mi.videoFps || null, - resolution: mi.resolution || null, - audioCodec: mi.audioCodec || null, - audioChannels: mi.audioChannels || null, - audioLanguages: mi.audioLanguages || null, - runTime: mi.runTime || null, - subtitles: mi.subtitles || null, - dynamicRange: mi.videoDynamicRangeType || null, - imageUrl: null, - }); - }); - Object.values(seasons).forEach(eps => eps.sort((a, b) => a.episodeNumber - b.episodeNumber)); - - // Sonarr only exposes screenshot URLs on the per-episode resource, so enrich file-backed episodes in a second pass. - const epIdsNeedingImages = []; - for (const eps of Object.values(seasons)) { - for (const ep of eps) { - if (ep.hasFile && ep.id) epIdsNeedingImages.push(ep.id); - } - } - if (epIdsNeedingImages.length > 0) { - const imageMap = {}; - const BATCH_SIZE = 15; - for (let i = 0; i < epIdsNeedingImages.length; i += BATCH_SIZE) { - const batch = epIdsNeedingImages.slice(i, i + BATCH_SIZE); - const results = await Promise.allSettled( - batch.map(eid => fetchWithTimeout(SONARR_HOST + '/api/v3/episode/' + eid + '?apikey=' + SONARR_API_KEY, 5000)) - ); - results.forEach((result, j) => { - if (result.status === 'fulfilled' && result.value && result.value.images && result.value.images.length > 0) { - const sshot = result.value.images.find(img => img.coverType === 'screenshot'); - if (sshot && sshot.remoteUrl) { - imageMap[batch[j]] = '/api/poster?url=' + encodeURIComponent(sshot.remoteUrl); - } - } - }); - } - for (const eps of Object.values(seasons)) { - for (const ep of eps) { - if (imageMap[ep.id]) ep.imageUrl = imageMap[ep.id]; - } - } - } - - res.json({ seasons }); - } catch (err) { - console.error('Episode fetch error:', err.message); - res.status(502).json({ error: 'Failed to fetch episodes' }); - } -}); - -router.get('/library/movie/:id/file', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - try { - const files = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/moviefile?movieId=${encodeURIComponent(req.params.id)}&apikey=${RADARR_API_KEY}`, 8000 - ); - const f = Array.isArray(files) ? files[0] : files; - if (!f) return res.json(null); - const mi = f.mediaInfo || {}; - res.json({ - path: f.path || null, - relativePath: f.relativePath || null, - size: f.size || 0, - quality: f.quality?.quality?.name || null, - videoCodec: mi.videoCodec || null, - videoFps: mi.videoFps || null, - resolution: mi.resolution || null, - audioCodec: mi.audioCodec || null, - audioChannels: mi.audioChannels || null, - audioLanguages: mi.audioLanguages || null, - runTime: mi.runTime || null, - subtitles: mi.subtitles || null, - videoBitDepth: mi.videoBitDepth || null, - dynamicRange: mi.videoDynamicRangeType || null, - }); - } catch (err) { - console.error('Movie file info error:', err.message); - res.status(502).json({ error: err.message }); - } -}); - -router.get('/library/artists/:id/files', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const artist = await fetchWithTimeout( - `${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(req.params.id)}?apikey=${LIDARR_API_KEY}`, 10000 - ); - const artistPath = artist.path; - if (!artistPath) return res.json({ path: null, folders: [] }); - - const hostPath = artistPath.replace(/^\/data\//, '/hostdocker/'); - let folders = []; - try { - const lsOut = execFileSync('find', [hostPath, '-type', 'f'], { encoding: 'utf8', timeout: 10000 }); - const files = lsOut.trim().split('\n').filter(Boolean); - const grouped = {}; - for (const f of files) { - const rel = f.replace(hostPath + '/', ''); - const parts = rel.split('/'); - const folder = parts.length > 1 ? parts[0] : '.'; - if (!grouped[folder]) grouped[folder] = []; - grouped[folder].push({ - name: parts[parts.length - 1], - path: rel, - size: (() => { try { return statSync(f).size; } catch { return 0; } })(), - }); - } - folders = Object.entries(grouped).map(([name, files]) => ({ - name, - fileCount: files.length, - totalSize: files.reduce((s, f) => s + f.size, 0), - files, - })).sort((a, b) => a.name.localeCompare(b.name)); - } catch (e) { - console.error('File tree error:', e.message); - } - res.json({ path: artistPath, folders }); - } catch (err) { - console.error('Artist file tree error:', err.message); - res.status(502).json({ error: 'Failed to fetch file tree' }); - } -}); - -router.get('/library/artists/:id/albums', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const [albums, artist] = await Promise.all([ - fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${encodeURIComponent(req.params.id)}&apikey=${LIDARR_API_KEY}`, 10000), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(req.params.id)}?apikey=${LIDARR_API_KEY}`, 10000).catch(() => null), - ]); - const formatted = albums.map(a => ({ - id: a.id, title: a.title, releaseDate: a.releaseDate, - genres: a.genres || [], overview: a.overview, monitored: a.monitored, - albumType: a.albumType || 'Album', - coverUrl: pickImageUrl(a.images, 'cover'), - trackCount: a.statistics?.trackCount || 0, - trackFileCount: a.statistics?.trackFileCount || 0, - sizeOnDisk: a.statistics?.sizeOnDisk || 0, - percentOfTracks: a.statistics?.percentOfTracks || 0, - })); - formatted.sort((a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)); - res.json({ - albums: formatted, - artist: artist ? { - id: artist.id, - foreignArtistId: artist.foreignArtistId, - artistName: artist.artistName, - metadataProfileId: artist.metadataProfileId, - } : null, - }); - } catch (err) { - console.error('Album fetch error:', err.message); - res.status(502).json({ error: 'Failed to fetch albums' }); - } -}); - -async function ensureExtendedMetadataProfile() { - const profiles = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`, 10000); - // Selective album adds depend on Lidarr seeing EPs and singles, not just the default album set. - const wantPrimary = new Set(['Album', 'EP', 'Single']); - const matching = profiles.find(p => { - const primAllowed = new Set((p.primaryAlbumTypes || []).filter(t => t.allowed).map(t => t.albumType?.name)); - return [...wantPrimary].every(n => primAllowed.has(n)); - }); - if (matching) return matching.id; - const base = profiles[0]; - if (!base) throw new Error('No Lidarr metadata profile to clone'); - const newProf = { - name: 'Standard Extended', - primaryAlbumTypes: (base.primaryAlbumTypes || []).map(t => ({ - ...t, - allowed: ['Album', 'EP', 'Single'].includes(t.albumType?.name), - })), - secondaryAlbumTypes: (base.secondaryAlbumTypes || []).map(t => ({ - ...t, - allowed: t.albumType?.name === 'Studio' ? true : !!t.allowed, - })), - releaseStatuses: (base.releaseStatuses || []).map(t => ({ - ...t, - allowed: t.releaseStatus?.name === 'Official' ? true : !!t.allowed, - })), - }; - const resp = await fetch(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newProf), - }); - if (!resp.ok) { - const txt = await resp.text().catch(() => ''); - throw new Error(`Create metadata profile failed: HTTP ${resp.status} ${txt.substring(0, 120)}`); - } - const created = await resp.json(); - return created.id; -} - -router.post('/library/artists/:id/albums/add', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const { selectedAlbumTitles } = req.body || {}; - if (!Array.isArray(selectedAlbumTitles) || selectedAlbumTitles.length === 0) { - return res.status(400).json({ error: 'selectedAlbumTitles required' }); - } - const artistId = req.params.id; - let logId = null; - - try { - const artist = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(artistId)}?apikey=${LIDARR_API_KEY}`, 10000); - if (!artist?.id) return res.status(404).json({ error: 'Artist not found' }); - - logId = logActivity('add', `Adding ${selectedAlbumTitles.length} extra album(s) to "${artist.artistName}"...`, - { artistId: artist.id, count: selectedAlbumTitles.length }, 'pending', - { service: 'lidarr', artistName: artist.artistName }); - - let profileBumped = false; - try { - const extId = await ensureExtendedMetadataProfile(); - if (artist.metadataProfileId !== extId) { - artist.metadataProfileId = extId; - const putResp = await fetch(`${LIDARR_HOST}/api/v1/artist/${artist.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(artist), - }); - if (putResp.ok) { profileBumped = true; addLogStep(logId, 'Switched artist to extended metadata profile (Album+EP+Single)', 'success'); } - else addLogStep(logId, `Warning: could not switch metadata profile (HTTP ${putResp.status})`, 'warning'); - } - } catch (e) { - addLogStep(logId, `Metadata profile setup failed: ${e.message}`, 'warning'); - } - - if (profileBumped) { - try { - const r = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'RefreshArtist', artistId: artist.id }), - }); - if (!r.ok) addLogStep(logId, `Warning: RefreshArtist HTTP ${r.status}`, 'warning'); - else addLogStep(logId, 'Refreshing artist metadata...', 'pending'); - } catch (e) { - addLogStep(logId, `RefreshArtist failed: ${e.message}`, 'warning'); - } - } - - const normalize = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') - .replace(/\s*\(.*?\)\s*/g, ' ').replace(/\s*\[.*?\]\s*/g, ' ') - .replace(/ - (single|ep)$/i, '').replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ').trim(); - - const targetNorms = selectedAlbumTitles.map(t => ({ original: t, norm: normalize(t) })); - - let albums = []; - const pollCount = profileBumped ? 15 : 1; - for (let attempt = 0; attempt < pollCount; attempt++) { - try { - albums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${artist.id}&apikey=${LIDARR_API_KEY}`, 10000); - } catch (e) { } - const matchedAll = targetNorms.every(sel => - albums.some(a => { - const an = normalize(a.title); - if (an === sel.norm) return true; - if (sel.norm.length < 3 || an.length < 3) return false; - const shorter = sel.norm.length <= an.length ? sel.norm : an; - const longer = sel.norm.length > an.length ? sel.norm : an; - if (shorter.length / longer.length < 0.6) return false; - return longer.includes(shorter); - }) - ); - if (matchedAll) break; - if (attempt < pollCount - 1) await new Promise(r => setTimeout(r, 2000)); - } - addLogStep(logId, `Lidarr now reports ${albums.length} total album(s) for artist`, 'pending'); - - const matched = []; - const matchedOriginals = new Set(); - const albumsWithMatches = albums.map(album => { - const albumNorm = normalize(album.title || ''); - const matchEntry = targetNorms.find(sel => sel.norm === albumNorm) || - targetNorms.find(sel => { - if (sel.norm.length < 3 || albumNorm.length < 3) return false; - const shorter = sel.norm.length <= albumNorm.length ? sel.norm : albumNorm; - const longer = sel.norm.length > albumNorm.length ? sel.norm : albumNorm; - if (shorter.length / longer.length < 0.6) return false; - return longer.includes(shorter); - }); - return { album, matchEntry }; - }).filter(({ matchEntry }) => matchEntry != null); - - const albumsToSearch = []; - await Promise.all(albumsWithMatches.map(async ({ album, matchEntry }) => { - matchedOriginals.add(matchEntry.original); - if (!album.monitored) { - album.monitored = true; - try { - await fetch(`${LIDARR_HOST}/api/v1/album/${album.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(album), - }); - } catch (e) { - addLogStep(logId, `Failed to monitor "${album.title}": ${e.message}`, 'error'); - return; - } - } - matched.push(album.title); - albumsToSearch.push({ id: album.id, title: album.title }); - })); - - const unmatchedAlbumTitles = selectedAlbumTitles.filter(t => !matchedOriginals.has(t)); - if (matched.length > 0) addLogStep(logId, `Monitoring ${matched.length} album(s): ${matched.join(', ')}`, 'success'); - if (unmatchedAlbumTitles.length > 0) addLogStep(logId, `${unmatchedAlbumTitles.length} not in Lidarr (will try Soulseek): ${unmatchedAlbumTitles.join(', ')}`, 'warning'); - - if (albumsToSearch.length > 0) { - try { - const r = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'AlbumSearch', albumIds: albumsToSearch.map(a => a.id) }), - }); - if (!r.ok) { - const txt = await r.text().catch(() => ''); - addLogStep(logId, `Lidarr search HTTP ${r.status} ${txt.substring(0, 100)}`, 'error'); - } else { - addLogStep(logId, `Lidarr search triggered for ${albumsToSearch.length} album(s)`, 'success'); - } - } catch (e) { - addLogStep(logId, `Lidarr search failed: ${e.message}`, 'error'); - } - } - - if (SLSKD_API_KEY) { - const searchAlbums = [ - ...albumsToSearch, - ...unmatchedAlbumTitles.map(t => ({ id: null, title: t })), - ]; - const resolvedArtistName = artist.artistName; - (async () => { - addLogStep(logId, `Starting Soulseek search for ${searchAlbums.length} album(s)...`, 'pending'); - let queued = 0; let failed = 0; - for (const album of searchAlbums) { - try { - const dl = await searchAndDownloadSlskd(resolvedArtistName, album.title, logId, artist.id, album.id); - if (dl.success) queued++; else failed++; - } catch { failed++; } - await new Promise(r => setTimeout(r, 1500)); - } - if (queued > 0) { - updateLogEntry(logId, { status: 'success', message: `"${resolvedArtistName}" — ${queued} extra album(s) downloading` }); - } else { - addLogStep(logId, `Soulseek queued nothing (failed: ${failed})`, 'warning'); - } - })().catch(err => { - addLogStep(logId, `Soulseek background error: ${err.message}`, 'error'); - }); - } - - refreshLibraryCache(); - res.json({ - success: true, - monitored: albumsToSearch.length, - unmatched: unmatchedAlbumTitles, - profileBumped, - }); - } catch (err) { - if (logId) updateLogEntry(logId, { status: 'error', message: `Add extra albums failed: ${err.message}` }); - console.error('Add extra albums error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// APPENDED ROUTES — Lookup, Profiles, Add, Delete -// ═══════════════════════════════════════════════════════════════════════════════ - -// ─── Lookup ───────────────────────────────────────────────────────────────── - -router.get('/lookup/movie', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - const term = req.query.term; - if (!term) return res.status(400).json({ error: 'Missing term' }); - try { - const results = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/movie/lookup?term=${encodeURIComponent(term)}&apikey=${RADARR_API_KEY}`, 10000 - ); - res.json(results.slice(0, 20).map(m => ({ - tmdbId: m.tmdbId, imdbId: m.imdbId, title: m.title, year: m.year, - overview: m.overview, genres: m.genres || [], ratings: m.ratings, - runtime: m.runtime, studio: m.studio, - posterUrl: pickImageUrl(m.images, 'poster'), - inLibrary: libraryCache.movies.some(lm => lm.tmdbId === m.tmdbId), - }))); - } catch (err) { - console.error('Movie lookup error:', err.message); - res.json([]); - } -}); - -router.get('/lookup/series', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - const term = req.query.term; - if (!term) return res.status(400).json({ error: 'Missing term' }); - try { - const results = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/series/lookup?term=${encodeURIComponent(term)}&apikey=${SONARR_API_KEY}`, 10000 - ); - res.json(results.slice(0, 20).map(s => ({ - tvdbId: s.tvdbId, title: s.title, year: s.year, - overview: s.overview, genres: s.genres || [], ratings: s.ratings, - network: s.network, seasonCount: s.seasonCount, - posterUrl: pickImageUrl(s.images, 'poster'), - inLibrary: libraryCache.series.some(ls => ls.tvdbId === s.tvdbId), - seasons: (s.seasons || []).map(sn => ({ - seasonNumber: sn.seasonNumber, - monitored: sn.monitored, - episodeCount: sn.statistics?.totalEpisodeCount || 0, - })), - }))); - } catch (err) { - console.error('Series lookup error:', err.message); - res.json([]); - } -}); - -router.get('/lookup/music', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const term = req.query.term; - if (!term) return res.status(400).json({ error: 'Missing term' }); - - const simScore = (query, target) => { - const q = query.toLowerCase().trim(); - const t = target.toLowerCase().trim(); - if (q === t) return 1.0; - if (t.startsWith(q) || q.startsWith(t)) return 0.9; - if (t.includes(q) || q.includes(t)) return 0.8; - const qw = new Set(q.split(/\s+/)); - const tw = new Set(t.split(/\s+/)); - const common = [...qw].filter(w => tw.has(w)).length; - const union = new Set([...qw, ...tw]).size; - return union > 0 ? common / union : 0; - }; - - try { - const [rawArtists, rawAlbums] = await Promise.all([ - fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/lookup?term=${encodeURIComponent(term)}&apikey=${LIDARR_API_KEY}`, 10000), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/album/lookup?term=${encodeURIComponent(term)}&apikey=${LIDARR_API_KEY}`, 10000).catch(() => []), - ]); - - const artistResults = (rawArtists || []).slice(0, 15).map(a => ({ - foreignArtistId: a.foreignArtistId, artistName: a.artistName, - overview: a.overview, genres: a.genres || [], - disambiguation: a.disambiguation, artistType: a.artistType, - posterUrl: pickImageUrl(a.images, 'poster') || pickImageUrl(a.images, 'cover') || null, - inLibrary: libraryCache.artists.some(la => la.foreignArtistId === a.foreignArtistId), - score: simScore(term, a.artistName), - })); - - const albumResults = []; - const singleResults = []; - for (const a of (rawAlbums || []).slice(0, 30)) { - const artist = a.artist || {}; - const isInLibrary = libraryCache.artists.some(la => la.foreignArtistId === artist.foreignArtistId); - const item = { - foreignAlbumId: a.foreignAlbumId, - title: a.title, disambiguation: a.disambiguation || '', - releaseDate: a.releaseDate, - albumType: a.albumType || 'Album', - genres: a.genres || [], - artistName: artist.artistName || '', - foreignArtistId: artist.foreignArtistId || '', - posterUrl: pickImageUrl(a.images, 'cover') || null, - inLibrary: isInLibrary, - score: simScore(term, a.title), - }; - if (a.albumType === 'Single' || a.albumType === 'EP') singleResults.push(item); - else albumResults.push(item); - } - - const noPoster = artistResults.filter(r => !r.posterUrl).slice(0, 5); - for (const artist of noPoster) { - const hit = itunesPosterCache.get(artist.artistName); - if (hit && (Date.now() - hit.ts < 3600000)) { if (hit.url) artist.posterUrl = hit.url; continue; } - try { - const itunes = await fetchWithTimeout( - 'https://itunes.apple.com/search?term=' + encodeURIComponent(artist.artistName) + '&entity=album&limit=1', 5000 - ); - const url = (itunes.results && itunes.results[0] && itunes.results[0].artworkUrl100) - ? itunes.results[0].artworkUrl100.replace('100x100', '600x600') : null; - itunesPosterCache.set(artist.artistName, { url, ts: Date.now() }); - if (url) artist.posterUrl = url; - } catch {} - await new Promise(r => setTimeout(r, 150)); - } - - const topArtistScore = Math.max(0, ...artistResults.map(r => r.score)); - const topAlbumScore = Math.max(0, ...albumResults.map(r => r.score)); - const topSingleScore = Math.max(0, ...singleResults.map(r => r.score)); - const maxScore = Math.max(topArtistScore, topAlbumScore, topSingleScore); - let topCategory = 'artists'; - if (topAlbumScore === maxScore && topAlbumScore > topArtistScore) topCategory = 'albums'; - else if (topSingleScore === maxScore && topSingleScore > topArtistScore) topCategory = 'singles'; - - artistResults.sort((a, b) => b.score - a.score); - albumResults.sort((a, b) => b.score - a.score); - singleResults.sort((a, b) => b.score - a.score); - - res.json({ artists: artistResults, albums: albumResults, singles: singleResults, topCategory }); - } catch (err) { - console.error('Music lookup error:', err.message); - res.json({ artists: [], albums: [], singles: [], topCategory: 'artists' }); - } -}); - -router.get('/lookup/music/albums', async (req, res) => { - const { artistName, foreignArtistId } = req.query; - if (!artistName) return res.status(400).json({ error: 'Missing artistName' }); - const normalize = s => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[.\-']/g, '').replace(/\s+/g, ' ').trim(); - const dedup = s => normalize(s.replace(/ - (Single|EP)$/i, '').replace(/\s*\((?:apple music edition|deluxe|deluxe edition|remastered|expanded|bonus track).*?\)$/i, '')); - const nameNorm = normalize(artistName); - - try { - const itunesArtistSearch = fetchWithTimeout( - `https://itunes.apple.com/search?term=${encodeURIComponent(artistName)}&entity=musicArtist&limit=5`, 10000 - ).catch(() => ({ results: [] })); - - const mbPromise = foreignArtistId - ? fetchWithTimeout( - `https://musicbrainz.org/ws/2/release-group?artist=${foreignArtistId}&fmt=json&limit=100`, - 10000, - { 'User-Agent': 'vibarr/1.0 (music lookup)' } - ).catch(() => ({ 'release-groups': [] })) - : Promise.resolve({ 'release-groups': [] }); - - const [itunesArtists, mbData] = await Promise.all([itunesArtistSearch, mbPromise]); - - const itunesArtist = (itunesArtists.results || []).find(a => - normalize(a.artistName) === nameNorm - ) || (itunesArtists.results || [])[0]; - - let itunesAlbums = []; - if (itunesArtist?.artistId) { - try { - const lookupData = await fetchWithTimeout( - `https://itunes.apple.com/lookup?id=${itunesArtist.artistId}&entity=album&limit=200`, 15000 - ); - itunesAlbums = (lookupData.results || []).filter(r => r.wrapperType === 'collection'); - } catch {} - } - - let searchAlbums = []; - try { - const searchData = await fetchWithTimeout( - `https://itunes.apple.com/search?term=${encodeURIComponent(artistName)}&entity=album&limit=200`, 10000 - ); - searchAlbums = (searchData.results || []).filter(a => normalize(a.artistName) === nameNorm); - } catch {} - - const seenIds = new Set(); - const allItunes = []; - for (const a of [...itunesAlbums, ...searchAlbums]) { - if (!seenIds.has(a.collectionId)) { - seenIds.add(a.collectionId); - const titleLower = a.collectionName.toLowerCase(); - let albumType = 'Album'; - if (titleLower.includes('- single')) albumType = 'Single'; - else if (titleLower.includes('- ep') || titleLower.endsWith(' ep')) albumType = 'EP'; - allItunes.push({ - id: 'itunes-' + a.collectionId, - title: a.collectionName, - releaseDate: a.releaseDate || null, - trackCount: a.trackCount || 0, - albumType, - coverUrl: a.artworkUrl100 ? a.artworkUrl100.replace('100x100', '600x600') : null, - source: 'itunes', - }); - } - } - - const mbGroups = (mbData['release-groups'] || []).map(g => { - const ptype = (g['primary-type'] || '').toLowerCase(); - const stypes = (g['secondary-types'] || []).map(s => s.toLowerCase()); - let albumType = 'Album'; - if (ptype === 'single' || stypes.includes('single')) albumType = 'Single'; - else if (ptype === 'ep' || stypes.includes('ep')) albumType = 'EP'; - return { - id: 'mb-' + g.id, - title: g.title, - releaseDate: g['first-release-date'] ? g['first-release-date'] + 'T00:00:00Z' : null, - trackCount: 0, - albumType, - coverUrl: `https://coverartarchive.org/release-group/${g.id}/front-250`, - source: 'musicbrainz', - }; - }); - - const seenTitles = new Set(allItunes.map(a => dedup(a.title))); - const merged = [...allItunes]; - for (const mb of mbGroups) { - const mbTitleNorm = dedup(mb.title); - if (!seenTitles.has(mbTitleNorm)) { - seenTitles.add(mbTitleNorm); - merged.push(mb); - } - } - - const typeOrder = { Album: 0, EP: 1, Single: 2 }; - merged.sort((a, b) => { - const ta = typeOrder[a.albumType] ?? 1; - const tb = typeOrder[b.albumType] ?? 1; - if (ta !== tb) return ta - tb; - return (b.releaseDate || '').localeCompare(a.releaseDate || ''); - }); - - res.json(merged); - } catch (err) { - console.error('Album lookup error:', err.message); - res.status(502).json({ error: 'Failed to fetch albums' }); - } -}); - -// ─── Profiles ─────────────────────────────────────────────────────────────── - -router.get('/profiles/movie', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - try { - const [profiles, rootFolders] = await Promise.all([ - fetchWithTimeout(`${RADARR_HOST}/api/v3/qualityprofile?apikey=${RADARR_API_KEY}`), - fetchWithTimeout(`${RADARR_HOST}/api/v3/rootfolder?apikey=${RADARR_API_KEY}`), - ]); - res.json({ - qualityProfiles: profiles.map(p => ({ id: p.id, name: p.name })), - rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), - minimumAvailabilities: [ - { value: 'announced', label: 'Announced' }, - { value: 'inCinemas', label: 'In Cinemas' }, - { value: 'released', label: 'Released' }, - ], - }); - } catch (err) { - console.error('Movie profiles error:', err.message); - res.status(502).json({ error: 'Failed to fetch profiles' }); - } -}); - -router.get('/profiles/series', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - try { - const [profiles, rootFolders] = await Promise.all([ - fetchWithTimeout(`${SONARR_HOST}/api/v3/qualityprofile?apikey=${SONARR_API_KEY}`), - fetchWithTimeout(`${SONARR_HOST}/api/v3/rootfolder?apikey=${SONARR_API_KEY}`), - ]); - res.json({ - qualityProfiles: profiles.map(p => ({ id: p.id, name: p.name })), - rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), - seriesTypes: [ - { value: 'standard', label: 'Standard' }, - { value: 'daily', label: 'Daily' }, - { value: 'anime', label: 'Anime' }, - ], - monitorOptions: [ - { value: 'all', label: 'All Episodes' }, - { value: 'future', label: 'Future Episodes' }, - { value: 'missing', label: 'Missing Episodes' }, - { value: 'existing', label: 'Existing Episodes' }, - { value: 'pilot', label: 'Pilot Only' }, - { value: 'firstSeason', label: 'First Season' }, - { value: 'lastSeason', label: 'Last Season' }, - { value: 'none', label: 'None' }, - ], - }); - } catch (err) { - console.error('Series profiles error:', err.message); - res.status(502).json({ error: 'Failed to fetch profiles' }); - } -}); - -router.get('/profiles/music', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const [qualityProfiles, metadataProfiles, rootFolders] = await Promise.all([ - fetchWithTimeout(`${LIDARR_HOST}/api/v1/qualityprofile?apikey=${LIDARR_API_KEY}`), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/rootfolder?apikey=${LIDARR_API_KEY}`), - ]); - res.json({ - qualityProfiles: qualityProfiles.map(p => ({ id: p.id, name: p.name })), - metadataProfiles: metadataProfiles.map(p => ({ id: p.id, name: p.name })), - rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), - monitorOptions: [ - { value: 'all', label: 'All Albums' }, - { value: 'future', label: 'Future Albums' }, - { value: 'missing', label: 'Missing Albums' }, - { value: 'existing', label: 'Existing Albums' }, - { value: 'first', label: 'First Album' }, - { value: 'latest', label: 'Latest Album' }, - { value: 'none', label: 'None' }, - ], - }); - } catch (err) { - console.error('Music profiles error:', err.message); - res.status(502).json({ error: 'Failed to fetch profiles' }); - } -}); - -// ─── Add Media ────────────────────────────────────────────────────────────── - -router.post('/add/movie', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - const { tmdbId, qualityProfileId, rootFolderPath, minimumAvailability, monitored, searchForMovie } = req.body; - if (!tmdbId) return res.status(400).json({ error: 'Missing tmdbId' }); - const logId = logActivity('add', `Adding movie (tmdb:${tmdbId}) to Radarr...`, { tmdbId }, 'pending', { service: 'radarr', tmdbId }); - try { - const lookup = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/movie/lookup/tmdb?tmdbId=${tmdbId}&apikey=${RADARR_API_KEY}` - ); - const movieData = Array.isArray(lookup) ? lookup[0] : lookup; - if (!movieData) throw new Error('Movie not found'); - - const resp = await fetch(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...movieData, - qualityProfileId: qualityProfileId || 1, - rootFolderPath: rootFolderPath || '/movies', - minimumAvailability: minimumAvailability || 'released', - monitored: monitored !== false, - addOptions: { searchForMovie: false }, - }), - }); - - let result; - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - const errMsg = err[0]?.errorMessage || err.message || `HTTP ${resp.status}`; - if (errMsg.toLowerCase().includes('already been added') || errMsg.toLowerCase().includes('already exists')) { - const allMovies = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`); - result = allMovies.find(m => m.tmdbId === Number(tmdbId)); - if (!result?.id) throw new Error(errMsg); - if (!result.monitored || (qualityProfileId && result.qualityProfileId !== qualityProfileId)) { - await fetch(`${RADARR_HOST}/api/v3/movie/${result.id}?apikey=${RADARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...result, monitored: true, qualityProfileId: qualityProfileId || result.qualityProfileId }), - }).catch(() => {}); - } - updateLogEntry(logId, { status: 'info', message: `"${result.title}" already in Radarr — re-enabled monitoring and triggering search`, context: { service: 'radarr', title: result.title, movieId: result.id } }); - } else { - throw new Error(errMsg); - } - } else { - result = await resp.json(); - updateLogEntry(logId, { status: 'success', message: `Added "${result.title}" to Radarr, searching for downloads`, context: { service: 'radarr', title: result.title, movieId: result.id } }); - refreshLibraryCache(); - } - - const posterUrl = pickArrImageUrl(movieData.images || [], 'poster', 'radarr'); - const pipelineKey = `radarr-${result.id}-${Date.now()}`; - addPipelineItem(pipelineKey, { service: 'radarr', title: result.title, subtitle: 'Movie', posterUrl, logId, movieId: result.id, retryId: result.id }); - advancePipeline(pipelineKey, 'searching'); - if (searchForMovie !== false) { - fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'MoviesSearch', movieIds: [result.id] }), - }).then(r => r.json()).then(cmd => { - if (cmd?.id) watchRadarrSearch(pipelineKey, cmd.id, result.id, logId, result.title).catch(e => console.error('watchRadarrSearch add:', e.message)); - }).catch(() => {}); - } - - res.json({ success: true, id: result.id, title: result.title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to add movie: ${err.message}` }); - console.error('Add movie error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -// ─── Helpers for watch search ────────────────────────────────────────────── - -async function watchRadarrSearch(pipelineKey, commandId, movieId, logId, movieTitle) { - const RADARR_HOST = CONFIG.RADARR_HOST; - const RADARR_API_KEY = CONFIG.RADARR_API_KEY; - // Arr search commands can complete without grabbing anything; watchers confirm the downstream result first. - try { - let completed = false; - for (let i = 0; i < 30; i++) { - await new Promise(r => setTimeout(r, 3000)); - try { - const cmd = await fetchWithTimeout(`${RADARR_HOST}/api/v3/command/${commandId}?apikey=${RADARR_API_KEY}`, 8000); - if (cmd.status === 'completed') { - completed = true; - const grabUrl = `${RADARR_HOST}/api/v3/release?movieId=${movieId}&apikey=${RADARR_API_KEY}`; - let grabbed = 0; - try { - const releases = await fetchWithTimeout(grabUrl, 8000); - grabbed = (Array.isArray(releases) ? releases : []).filter(r => r.grabbed).length; - } catch {} - if (grabbed > 0) { - advancePipeline(pipelineKey, 'grabbed', { progress: null }); - if (logId) addLogStep(logId, `Grabbed ${grabbed} release(s) for "${movieTitle}"`, 'success'); - } else { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - if (logId) addLogStep(logId, 'Search completed — no releases grabbed (may appear in queue shortly)', 'pending'); - } - break; - } else if (cmd.status === 'failed') { - if (logId) addLogStep(logId, `Search command failed: ${cmd.errorMessage || 'unknown'}`, 'error'); - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - break; - } - } catch {} - } - if (!completed) { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - } - } catch (err) { - console.error('watchRadarrSearch error:', err.message); - } -} - -async function watchSonarrSearch(pipelineKey, commandId, seriesId, seasonNumber, logId, seriesTitle) { - const SONARR_HOST = CONFIG.SONARR_HOST; - const SONARR_API_KEY = CONFIG.SONARR_API_KEY; - try { - let completed = false; - for (let i = 0; i < 30; i++) { - await new Promise(r => setTimeout(r, 3000)); - try { - const cmd = await fetchWithTimeout(`${SONARR_HOST}/api/v3/command/${commandId}?apikey=${SONARR_API_KEY}`, 8000); - if (cmd.status === 'completed') { - completed = true; - const histUrl = `${SONARR_HOST}/api/v3/history/series?seriesId=${seriesId}&pageSize=50&apikey=${SONARR_API_KEY}`; - let grabbed = 0; - try { - const history = await fetchWithTimeout(histUrl, 8000); - const records = history.records || history || []; - const since = Date.now() - 120000; - grabbed = (Array.isArray(records) ? records : []).filter(r => r.eventType === 'grabbed' && new Date(r.date).getTime() > since).length; - } catch {} - if (grabbed > 0) { - advancePipeline(pipelineKey, 'grabbed', { progress: null }); - if (logId) addLogStep(logId, `Grabbed ${grabbed} release(s) for "${seriesTitle}"`, 'success'); - } else { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - if (logId) addLogStep(logId, 'Search completed — no releases grabbed', 'pending'); - } - break; - } else if (cmd.status === 'failed') { - if (logId) addLogStep(logId, `Search command failed: ${cmd.errorMessage || 'unknown'}`, 'error'); - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - break; - } - } catch {} - } - if (!completed) { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - } - } catch (err) { - console.error('watchSonarrSearch error:', err.message); - } -} - -router.post('/add/series', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - const { tvdbId, qualityProfileId, rootFolderPath, seriesType, monitored, monitorOption, searchForMissingEpisodes, selectedSeasons } = req.body; - if (!tvdbId) return res.status(400).json({ error: 'Missing tvdbId' }); - const logId = logActivity('add', `Adding series (tvdb:${tvdbId}) to Sonarr...`, { tvdbId }, 'pending', { service: 'sonarr', tvdbId }); - try { - const lookupResults = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/series/lookup?term=tvdb:${tvdbId}&apikey=${SONARR_API_KEY}` - ); - const seriesData = Array.isArray(lookupResults) ? lookupResults[0] : lookupResults; - if (!seriesData) throw new Error('Series not found'); - - if (selectedSeasons && seriesData.seasons) { - seriesData.seasons = seriesData.seasons.map(s => ({ - ...s, - monitored: selectedSeasons.includes(s.seasonNumber), - })); - } - - const resp = await fetch(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...seriesData, - qualityProfileId: qualityProfileId || 1, - rootFolderPath: rootFolderPath || '/tv', - seriesType: seriesType || 'standard', - monitored: monitored !== false, - seasonFolder: true, - addOptions: { - monitor: selectedSeasons ? 'none' : (monitorOption || 'all'), - searchForMissingEpisodes: searchForMissingEpisodes !== false, - searchForCutoffUnmetEpisodes: false, - }, - }), - }); - - let result; - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - const errMsg = err[0]?.errorMessage || err.message || `HTTP ${resp.status}`; - if (errMsg.toLowerCase().includes('already been added') || errMsg.toLowerCase().includes('already exists')) { - const allSeries = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`); - result = allSeries.find(s => s.tvdbId === Number(tvdbId)); - if (!result?.id) throw new Error(errMsg); - const updatedSeries = { - ...result, - monitored: true, - seasons: result.seasons?.map(s => ({ - ...s, - monitored: selectedSeasons ? selectedSeasons.includes(s.seasonNumber) : s.monitored, - })) || [], - }; - await fetch(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedSeries), - }).catch(() => {}); - const seasonLabel = selectedSeasons ? ` (seasons ${selectedSeasons.join(', ')})` : ''; - updateLogEntry(logId, { status: 'info', message: `"${result.title}"${seasonLabel} already in Sonarr — re-enabled monitoring and triggering search`, context: { service: 'sonarr', title: result.title, seriesId: result.id, seasons: selectedSeasons } }); - } else { - throw new Error(errMsg); - } - } else { - result = await resp.json(); - const seasonLabel = selectedSeasons ? ` (seasons ${selectedSeasons.join(', ')})` : ''; - updateLogEntry(logId, { status: 'success', message: `Added "${result.title}"${seasonLabel} to Sonarr, searching for downloads`, context: { service: 'sonarr', title: result.title, seriesId: result.id, seasons: selectedSeasons } }); - refreshLibraryCache(); - } - - const seriesPoster = pickArrImageUrl(seriesData.images || [], 'poster', 'sonarr'); - if (selectedSeasons?.length) { - try { - const currentSeries = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`); - const selectedSeasonSet = new Set(selectedSeasons.map(Number)); - const updatedSeries = { - ...currentSeries, - monitored: true, - seasons: (currentSeries.seasons || []).map((season) => ({ - ...season, - monitored: selectedSeasonSet.has(season.seasonNumber), - })), - }; - const monitorResp = await fetch(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedSeries), - }); - if (monitorResp.ok) { - addLogStep(logId, `Enabled Sonarr monitoring for seasons ${selectedSeasons.join(', ')}`, 'info'); - } else { - addLogStep(logId, `Sonarr add completed, but season monitoring PUT returned HTTP ${monitorResp.status}`, 'warning'); - } - } catch (err) { - addLogStep(logId, `Failed to confirm Sonarr season monitoring: ${err.message}`, 'warning'); - } - } - const pipelineKey = `sonarr-${result.id}-add-${Date.now()}`; - const subtitle = selectedSeasons ? `Seasons ${selectedSeasons.join(', ')}` : 'All seasons'; - addPipelineItem(pipelineKey, { service: 'sonarr', title: result.title, subtitle, posterUrl: seriesPoster, logId, seriesId: result.id, seasonNumbers: selectedSeasons || null, retryId: result.id }); - advancePipeline(pipelineKey, 'searching'); - const singleSeasonSearch = selectedSeasons?.length === 1; - const cmdBody = singleSeasonSearch - ? { name: 'SeasonSearch', seriesId: result.id, seasonNumber: selectedSeasons[0] } - : { name: 'SeriesSearch', seriesId: result.id }; - fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cmdBody), - }).then(r => r.json()).then(cmd => { - if (cmd?.id) watchSonarrSearch(pipelineKey, cmd.id, result.id, singleSeasonSearch ? selectedSeasons[0] : null, logId, result.title).catch(e => console.error('watchSonarrSearch add:', e.message)); - }).catch(() => {}); - - res.json({ success: true, id: result.id, title: result.title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to add series: ${err.message}` }); - console.error('Add series error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -router.post('/add/music', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const { foreignArtistId, artistName, qualityProfileId, metadataProfileId, rootFolderPath, monitored, monitorOption, searchForMissingAlbums, selectedAlbums, selectedAlbumTitles } = req.body; - if (!foreignArtistId) return res.status(400).json({ error: 'Missing foreignArtistId' }); - - const hasAlbumSelection = selectedAlbumTitles && selectedAlbumTitles.length > 0; - const logId = logActivity('add', `Adding artist "${artistName}" to Lidarr...`, { foreignArtistId }, 'pending', { service: 'lidarr', artistName, foreignArtistId }); - - try { - addLogStep(logId, `Adding "${artistName}" to Lidarr${hasAlbumSelection ? ` (${selectedAlbumTitles.length} albums selected)` : ' (all albums)'}`, 'pending'); - - const addPayload = { - foreignArtistId, - artistName: artistName || '', - qualityProfileId: qualityProfileId || 1, - metadataProfileId: metadataProfileId || 1, - rootFolderPath: rootFolderPath || '/music', - monitored: true, - monitorNewItems: 'all', - addOptions: { - monitor: hasAlbumSelection ? 'none' : (monitorOption || 'all'), - searchForMissingAlbums: hasAlbumSelection ? false : (searchForMissingAlbums !== false), - }, - }; - - const resp = await fetch(`${LIDARR_HOST}/api/v1/artist?apikey=${LIDARR_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(addPayload), - }); - - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err[0]?.errorMessage || err.message || `HTTP ${resp.status}`); - } - const addResult = await resp.json(); - addLogStep(logId, `Artist "${addResult.artistName}" added to Lidarr (ID: ${addResult.id})`, 'success'); - - let albums = []; - addLogStep(logId, 'Waiting for Lidarr to import album metadata...', 'pending'); - for (let attempt = 0; attempt < 15; attempt++) { - await new Promise(r => setTimeout(r, 2000)); - try { - albums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${addResult.id}&apikey=${LIDARR_API_KEY}`, 10000); - } catch (e) { - console.log(`Album fetch attempt ${attempt + 1} error: ${e.message}`); - } - if (albums.length > 0) break; - } - if (albums.length > 0) { - addLogStep(logId, `Lidarr imported ${albums.length} album(s)`, 'success'); - } else { - addLogStep(logId, 'Warning: Lidarr returned 0 albums after 30s — artist may have no MusicBrainz releases', 'warning'); - } - - const normalize = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') - .replace(/\s*\(.*?\)\s*/g, ' ').replace(/\s*\[.*?\]\s*/g, ' ') - .replace(/ - (single|ep)$/i, '').replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ').trim(); - - const albumsToSearch = []; - let unmatchedAlbumTitles = []; - - if (hasAlbumSelection && albums.length > 0) { - // For curated album picks, import the artist first and opt matching albums into monitoring afterward. - const normalizedSelected = selectedAlbumTitles.map(t => ({ original: t, norm: normalize(t) })); - - const matched = []; - const matchedOriginals = new Set(); - - const albumsWithMatches = albums.map(album => { - const albumNorm = normalize(album.title || ''); - const matchEntry = normalizedSelected.find(sel => sel.norm === albumNorm) || - normalizedSelected.find(sel => { - if (sel.norm.length < 3 || albumNorm.length < 3) return false; - const shorter = sel.norm.length <= albumNorm.length ? sel.norm : albumNorm; - const longer = sel.norm.length > albumNorm.length ? sel.norm : albumNorm; - if (shorter.length / longer.length < 0.6) return false; - return longer.includes(shorter); - }); - return { album, matchEntry }; - }).filter(({ matchEntry }) => matchEntry != null); - - await Promise.all(albumsWithMatches.map(async ({ album, matchEntry }) => { - album.monitored = true; - matchedOriginals.add(matchEntry.original); - try { - await fetch(`${LIDARR_HOST}/api/v1/album/${album.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(album), - }); - matched.push(album.title); - albumsToSearch.push({ id: album.id, title: album.title }); - } catch (e) { - addLogStep(logId, `Failed to monitor "${album.title}": ${e.message}`, 'error'); - } - })); - - const unmatchedSelected = selectedAlbumTitles.filter(t => !matchedOriginals.has(t)); - - if (matched.length > 0) { - addLogStep(logId, `Monitoring ${matched.length}/${selectedAlbumTitles.length} albums: ${matched.join(', ')}`, 'success'); - } else { - addLogStep(logId, `Warning: Could not match any selected albums in Lidarr`, 'warning'); - } - if (unmatchedSelected.length > 0) { - unmatchedAlbumTitles = unmatchedSelected; - addLogStep(logId, `${unmatchedSelected.length} selected album(s) not found in Lidarr: ${unmatchedSelected.join(', ')}`, 'warning'); - } - } else if (albums.length > 0) { - for (const album of albums) { - if (album.monitored) { - albumsToSearch.push({ id: album.id, title: album.title }); - } - } - addLogStep(logId, `All ${albumsToSearch.length} albums monitored`, 'success'); - } - - if (hasAlbumSelection && albums.length === 0) { - unmatchedAlbumTitles = [...selectedAlbumTitles]; - addLogStep(logId, `Lidarr has no album metadata — will search Soulseek directly for ${selectedAlbumTitles.length} album(s)`, 'pending'); - } - - try { - const artistData = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${addResult.id}?apikey=${LIDARR_API_KEY}`, 5000); - if (!artistData.monitored) { - artistData.monitored = true; - const putResp = await fetch(`${LIDARR_HOST}/api/v1/artist/${addResult.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(artistData), - }); - if (putResp.ok) { - addLogStep(logId, 'Artist monitoring enabled', 'success'); - } else { - addLogStep(logId, `Warning: Could not enable artist monitoring (HTTP ${putResp.status})`, 'warning'); - } - } - } catch (e) { - addLogStep(logId, `Warning: Could not enable artist monitoring: ${e.message}`, 'warning'); - } - - if (albumsToSearch.length > 0) { - try { - const albumIds = albumsToSearch.map(a => a.id); - await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'AlbumSearch', albumIds }), - }); - addLogStep(logId, `Lidarr search triggered for ${albumIds.length} album(s)`, 'success'); - } catch (e) { - addLogStep(logId, `Lidarr search trigger failed: ${e.message}`, 'error'); - } - } - - if ((albumsToSearch.length > 0 || unmatchedAlbumTitles.length > 0) && SLSKD_API_KEY) { - const resolvedArtistName = addResult.artistName || artistName; - const searchAlbums = [ - ...albumsToSearch, - ...unmatchedAlbumTitles.map(t => ({ id: null, title: t })), - ]; - (async () => { - addLogStep(logId, `Starting Soulseek direct search for ${searchAlbums.length} album(s)...`, 'pending'); - let queued = 0; - let failed = 0; - const failedTitles = []; - - for (const album of searchAlbums) { - try { - const dlResult = await searchAndDownloadSlskd(resolvedArtistName, album.title, logId, addResult.id, album.id); - if (dlResult.success) { - queued++; - } else { - failed++; - failedTitles.push(`${album.title} (${dlResult.reason})`); - } - } catch (e) { - failed++; - failedTitles.push(`${album.title} (${e.message})`); - } - if (searchAlbums.indexOf(album) < searchAlbums.length - 1) { - await new Promise(r => setTimeout(r, 1500)); - } - } - - if (queued > 0) { - addLogStep(logId, `Soulseek: Queued downloads for ${queued}/${searchAlbums.length} album(s)`, 'success'); - updateLogEntry(logId, { status: 'success', message: `"${resolvedArtistName}" — ${queued} album(s) downloading via Soulseek` }); - } else if (failed > 0) { - addLogStep(logId, `Soulseek: No downloads queued. Failed: ${failedTitles.join('; ')}. Soularr will retry on next cycle.`, 'warning'); - } - - if (queued > 0) { - console.log('SLSKD downloads queued — auto-import pipeline will handle import when complete'); - } - })().catch(err => { - console.error('SLSKD background search error:', err.message); - addLogStep(logId, `Soulseek background search error: ${err.message}`, 'error'); - }); - } - - const albumInfo = albumsToSearch.length > 0 ? ` (${albumsToSearch.length} albums monitored)` : ''; - updateLogEntry(logId, { status: 'success', message: `Added "${addResult.artistName}" to Lidarr${albumInfo}, searching for downloads...` }); - refreshLibraryCache(); - res.json({ - success: true, id: addResult.id, artistName: addResult.artistName, - albumsMonitored: albumsToSearch.length, totalAlbums: albums.length, - details: albumsToSearch.map(a => a.title), - }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to add artist: ${err.message}` }); - console.error('Add music error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -// ─── Delete Media ─────────────────────────────────────────────────────────── - -router.delete('/delete/movie/:id', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - const { id } = req.params; - const deleteFiles = req.query.deleteFiles !== 'false'; - const logId = logActivity('delete', `Deleting movie ID ${id} from Radarr...`, { movieId: id, deleteFiles }, 'pending', { service: 'radarr' }); - try { - let title = `ID ${id}`; - try { - const info = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie/${id}?apikey=${RADARR_API_KEY}`); - title = info.title || title; - } catch {} - const resp = await fetch(`${RADARR_HOST}/api/v3/movie/${id}?deleteFiles=${deleteFiles}&addImportExclusion=false&apikey=${RADARR_API_KEY}`, { - method: 'DELETE', - }); - if (!resp.ok) { - throw new Error(parseArrError(await resp.text(), resp.status)); - } - updateLogEntry(logId, { status: 'success', message: `Deleted "${title}" from Radarr${deleteFiles ? ' (files removed)' : ''}`, context: { service: 'radarr', title } }); - refreshLibraryCache(); - res.json({ success: true, title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to delete movie: ${err.message}` }); - res.status(500).json({ error: err.message }); - } -}); - -router.delete('/delete/series/:id', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - const { id } = req.params; - const deleteFiles = req.query.deleteFiles !== 'false'; - const logId = logActivity('delete', `Deleting series ID ${id} from Sonarr...`, { seriesId: id, deleteFiles }, 'pending', { service: 'sonarr' }); - try { - let title = `ID ${id}`; - try { - const info = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`); - title = info.title || title; - } catch {} - const resp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?deleteFiles=${deleteFiles}&apikey=${SONARR_API_KEY}`, { - method: 'DELETE', - }); - if (!resp.ok) { - throw new Error(parseArrError(await resp.text(), resp.status)); - } - updateLogEntry(logId, { status: 'success', message: `Deleted "${title}" from Sonarr${deleteFiles ? ' (files removed)' : ''}`, context: { service: 'sonarr', title } }); - refreshLibraryCache(); - res.json({ success: true, title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to delete series: ${err.message}` }); - res.status(500).json({ error: err.message }); - } -}); - -router.delete('/delete/album/:id/files', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const trackFiles = await fetchWithTimeout( - `${LIDARR_HOST}/api/v1/trackfile?albumId=${encodeURIComponent(req.params.id)}&apikey=${LIDARR_API_KEY}`, 8000 - ); - if (!Array.isArray(trackFiles) || trackFiles.length === 0) - return res.json({ success: true, deleted: 0 }); - const ids = trackFiles.map(f => f.id); - await fetch(`${LIDARR_HOST}/api/v1/trackfile/bulk?apikey=${LIDARR_API_KEY}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ trackFileIds: ids }), - }); - res.json({ success: true, deleted: ids.length }); - } catch (err) { - console.error('Album file delete error:', err.message); - res.status(502).json({ error: 'Failed to delete album files' }); - } -}); - -router.delete('/delete/music/:id', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const { id } = req.params; - const deleteFiles = req.query.deleteFiles !== 'false'; - const logId = logActivity('delete', `Deleting artist ID ${id} from Lidarr...`, { artistId: id, deleteFiles }, 'pending', { service: 'lidarr' }); - try { - let artistName = `ID ${id}`; - try { - const info = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${id}?apikey=${LIDARR_API_KEY}`); - artistName = info.artistName || artistName; - } catch {} - const resp = await fetch(`${LIDARR_HOST}/api/v1/artist/${id}?deleteFiles=${deleteFiles}&apikey=${LIDARR_API_KEY}`, { - method: 'DELETE', - }); - if (!resp.ok) { - throw new Error(parseArrError(await resp.text(), resp.status)); - } - updateLogEntry(logId, { status: 'success', message: `Deleted "${artistName}" from Lidarr${deleteFiles ? ' (files removed)' : ''}`, context: { service: 'lidarr', artistName } }); - refreshLibraryCache(); - res.json({ success: true, artistName }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to delete artist: ${err.message}` }); - res.status(500).json({ error: err.message }); - } -}); - -export default router; +export { refreshLibraryCache, startLibraryCacheRefresh } from './library/index.js'; diff --git a/backend/src/library/arrConfig.js b/backend/src/library/arrConfig.js new file mode 100644 index 0000000..c4cc03e --- /dev/null +++ b/backend/src/library/arrConfig.js @@ -0,0 +1,11 @@ +import { CONFIG } from '../config.js'; + +export const { + RADARR_HOST, + RADARR_API_KEY, + SONARR_HOST, + SONARR_API_KEY, + LIDARR_HOST, + LIDARR_API_KEY, + SLSKD_API_KEY, +} = CONFIG; diff --git a/backend/src/library/cacheRefresh.js b/backend/src/library/cacheRefresh.js new file mode 100644 index 0000000..5fd0407 --- /dev/null +++ b/backend/src/library/cacheRefresh.js @@ -0,0 +1,147 @@ +import { fetchWithTimeout, pickImageUrl, pickArrImageUrl } from '../utils.js'; +import { libraryCache, registerInterval } from '../state.js'; +import { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY } from './arrConfig.js'; + +export async function refreshLibraryCache() { + const tasks = []; + + if (SONARR_API_KEY) { + tasks.push( + (async () => { + try { + const series = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`, 10000); + libraryCache.series = series.map(s => ({ + id: s.id, + tvdbId: s.tvdbId, + title: s.title, + sortTitle: s.sortTitle, + year: s.year, + overview: s.overview, + network: s.network, + genres: s.genres || [], + ratings: s.ratings, + seasonCount: s.statistics?.seasonCount || s.seasonCount || 0, + totalEpisodeCount: s.statistics?.totalEpisodeCount || 0, + episodeFileCount: s.statistics?.episodeFileCount || 0, + sizeOnDisk: s.statistics?.sizeOnDisk || 0, + posterUrl: pickArrImageUrl(s.images, 'poster', 'sonarr'), + status: s.status, + path: s.path, + monitored: s.monitored, + })); + } catch (err) { + console.error('Library cache - Sonarr:', err.message); + } + })(), + ); + } + + if (RADARR_API_KEY) { + tasks.push( + (async () => { + try { + const movies = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`, 10000); + libraryCache.movies = movies.map(m => ({ + id: m.id, + tmdbId: m.tmdbId, + title: m.title, + sortTitle: m.sortTitle, + year: m.year, + overview: m.overview, + genres: m.genres || [], + ratings: m.ratings, + runtime: m.runtime, + posterUrl: pickArrImageUrl(m.images, 'poster', 'radarr'), + hasFile: m.hasFile, + monitored: m.monitored, + status: m.status, + path: m.path, + sizeOnDisk: m.sizeOnDisk || 0, + quality: m.movieFile?.quality?.quality?.name || null, + })); + } catch (err) { + console.error('Library cache - Radarr:', err.message); + } + })(), + ); + } + + if (LIDARR_API_KEY) { + tasks.push( + (async () => { + try { + const artists = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist?apikey=${LIDARR_API_KEY}`, 10000); + const artistList = artists.map(a => ({ + id: a.id, + foreignArtistId: a.foreignArtistId, + artistName: a.artistName, + sortName: a.sortName, + overview: a.overview, + genres: a.genres || [], + posterUrl: pickImageUrl(a.images, 'poster') || pickImageUrl(a.images, 'cover') || null, + monitored: a.monitored, + status: a.status, + path: a.path, + albumCount: a.statistics?.albumCount || 0, + trackFileCount: a.statistics?.trackFileCount || 0, + trackCount: a.statistics?.trackCount || 0, + sizeOnDisk: a.statistics?.sizeOnDisk || 0, + })); + // Lidarr artist records often lack art; borrow the first album cover so library cards stay populated. + const noPoster = artistList.filter(a => !a.posterUrl); + if (noPoster.length > 0) { + await Promise.allSettled( + noPoster.map(async a => { + try { + const albums = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/album?artistId=${a.id}&apikey=${LIDARR_API_KEY}`, + 10000, + ); + for (const alb of albums) { + const cover = pickImageUrl(alb.images, 'cover'); + if (cover) { + a.posterUrl = cover; + break; + } + } + } catch {} + }), + ); + } + libraryCache.artists = artistList; + try { + const allAlbums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?apikey=${LIDARR_API_KEY}`, 15000); + const _dlByArtist = {}; + allAlbums.forEach(a => { + if ((a.statistics?.trackFileCount || 0) > 0) _dlByArtist[a.artistId] = (_dlByArtist[a.artistId] || 0) + 1; + }); + artistList.forEach(a => { + a.downloadedAlbumCount = _dlByArtist[a.id] || 0; + }); + // Keep the lightweight library cache scoped to albums that already have files on disk. + libraryCache.albums = allAlbums + .filter(a => (a.statistics?.trackFileCount || 0) > 0) + .map(a => ({ + id: a.id, + title: a.title, + artistId: a.artistId, + coverUrl: pickImageUrl(a.images, 'cover') || null, + })); + } catch (e) { + console.error('Library cache - Lidarr albums:', e.message); + } + } catch (err) { + console.error('Library cache - Lidarr:', err.message); + } + })(), + ); + } + + await Promise.allSettled(tasks); + libraryCache.lastRefresh = new Date().toISOString(); +} + +export function startLibraryCacheRefresh() { + refreshLibraryCache(); + registerInterval(setInterval(refreshLibraryCache, 60000)); +} diff --git a/backend/src/library/helpers/metadataProfile.js b/backend/src/library/helpers/metadataProfile.js new file mode 100644 index 0000000..55c2839 --- /dev/null +++ b/backend/src/library/helpers/metadataProfile.js @@ -0,0 +1,41 @@ +import { fetchWithTimeout } from '../../utils.js'; +import { LIDARR_HOST, LIDARR_API_KEY } from '../arrConfig.js'; + +export async function ensureExtendedMetadataProfile() { + // Selective album adds depend on Lidarr seeing EPs and singles, not just the default album set. + const profiles = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`, 10000); + const wantPrimary = new Set(['Album', 'EP', 'Single']); + const matching = profiles.find(p => { + const primAllowed = new Set((p.primaryAlbumTypes || []).filter(t => t.allowed).map(t => t.albumType?.name)); + return [...wantPrimary].every(n => primAllowed.has(n)); + }); + if (matching) return matching.id; + const base = profiles[0]; + if (!base) throw new Error('No Lidarr metadata profile to clone'); + const newProf = { + name: 'Standard Extended', + primaryAlbumTypes: (base.primaryAlbumTypes || []).map(t => ({ + ...t, + allowed: ['Album', 'EP', 'Single'].includes(t.albumType?.name), + })), + secondaryAlbumTypes: (base.secondaryAlbumTypes || []).map(t => ({ + ...t, + allowed: t.albumType?.name === 'Studio' ? true : !!t.allowed, + })), + releaseStatuses: (base.releaseStatuses || []).map(t => ({ + ...t, + allowed: t.releaseStatus?.name === 'Official' ? true : !!t.allowed, + })), + }; + const resp = await fetch(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newProf), + }); + if (!resp.ok) { + const txt = await resp.text().catch(() => ''); + throw new Error(`Create metadata profile failed: HTTP ${resp.status} ${txt.substring(0, 120)}`); + } + const created = await resp.json(); + return created.id; +} diff --git a/backend/src/library/helpers/musicTitle.js b/backend/src/library/helpers/musicTitle.js new file mode 100644 index 0000000..575c119 --- /dev/null +++ b/backend/src/library/helpers/musicTitle.js @@ -0,0 +1,66 @@ +export function normalizeAlbumTitle(s) { + return (s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s*\(.*?\)\s*/g, ' ') + .replace(/\s*\[.*?\]\s*/g, ' ') + .replace(/ - (single|ep)$/i, '') + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function normalizeLookupArtistName(s) { + return s + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[.\-']/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function dedupLookupAlbumTitle(s) { + return normalizeLookupArtistName( + s + .replace(/ - (Single|EP)$/i, '') + .replace(/\s*\((?:apple music edition|deluxe|deluxe edition|remastered|expanded|bonus track).*?\)$/i, ''), + ); +} + +export function simScore(query, target) { + const q = query.toLowerCase().trim(); + const t = target.toLowerCase().trim(); + if (q === t) return 1.0; + if (t.startsWith(q) || q.startsWith(t)) return 0.9; + if (t.includes(q) || q.includes(t)) return 0.8; + const qw = new Set(q.split(/\s+/)); + const tw = new Set(t.split(/\s+/)); + const common = [...qw].filter(w => tw.has(w)).length; + const union = new Set([...qw, ...tw]).size; + return union > 0 ? common / union : 0; +} + +export function fuzzyAlbumTitleMatch(selNorm, albumNorm) { + if (selNorm === albumNorm) return true; + if (selNorm.length < 3 || albumNorm.length < 3) return false; + const shorter = selNorm.length <= albumNorm.length ? selNorm : albumNorm; + const longer = selNorm.length > albumNorm.length ? selNorm : albumNorm; + if (shorter.length / longer.length < 0.6) return false; + return longer.includes(shorter); +} + +export function findAlbumMatchEntry(albumTitle, targetNorms) { + const albumNorm = normalizeAlbumTitle(albumTitle || ''); + return ( + targetNorms.find(sel => sel.norm === albumNorm) || + targetNorms.find(sel => fuzzyAlbumTitleMatch(sel.norm, albumNorm)) + ); +} + +export function allSelectedAlbumsMatched(albums, targetNorms) { + return targetNorms.every(sel => + albums.some(a => fuzzyAlbumTitleMatch(sel.norm, normalizeAlbumTitle(a.title))), + ); +} diff --git a/backend/src/library/helpers/watchSearch.js b/backend/src/library/helpers/watchSearch.js new file mode 100644 index 0000000..ec37f8d --- /dev/null +++ b/backend/src/library/helpers/watchSearch.js @@ -0,0 +1,87 @@ +import { CONFIG } from '../../config.js'; +import { fetchWithTimeout } from '../../utils.js'; +import { advancePipeline, addLogStep } from '../../state.js'; + +// Arr search commands can complete without grabbing anything; these watchers verify the downstream result before moving pipeline state. +export async function watchRadarrSearch(pipelineKey, commandId, movieId, logId, movieTitle) { + const { RADARR_HOST, RADARR_API_KEY } = CONFIG; + try { + let completed = false; + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 3000)); + try { + const cmd = await fetchWithTimeout(`${RADARR_HOST}/api/v3/command/${commandId}?apikey=${RADARR_API_KEY}`, 8000); + if (cmd.status === 'completed') { + completed = true; + const grabUrl = `${RADARR_HOST}/api/v3/release?movieId=${movieId}&apikey=${RADARR_API_KEY}`; + let grabbed = 0; + try { + const releases = await fetchWithTimeout(grabUrl, 8000); + grabbed = (Array.isArray(releases) ? releases : []).filter(r => r.grabbed).length; + } catch {} + if (grabbed > 0) { + advancePipeline(pipelineKey, 'grabbed', { progress: null }); + if (logId) addLogStep(logId, `Grabbed ${grabbed} release(s) for "${movieTitle}"`, 'success'); + } else { + advancePipeline(pipelineKey, 'searching', { canRetry: true }); + if (logId) + addLogStep(logId, 'Search completed — no releases grabbed (may appear in queue shortly)', 'pending'); + } + break; + } else if (cmd.status === 'failed') { + if (logId) addLogStep(logId, `Search command failed: ${cmd.errorMessage || 'unknown'}`, 'error'); + advancePipeline(pipelineKey, 'searching', { canRetry: true }); + break; + } + } catch {} + } + if (!completed) { + advancePipeline(pipelineKey, 'searching', { canRetry: true }); + } + } catch (err) { + console.error('watchRadarrSearch error:', err.message); + } +} + +export async function watchSonarrSearch(pipelineKey, commandId, seriesId, seasonNumber, logId, seriesTitle) { + const { SONARR_HOST, SONARR_API_KEY } = CONFIG; + try { + let completed = false; + for (let i = 0; i < 30; i++) { + await new Promise(r => setTimeout(r, 3000)); + try { + const cmd = await fetchWithTimeout(`${SONARR_HOST}/api/v3/command/${commandId}?apikey=${SONARR_API_KEY}`, 8000); + if (cmd.status === 'completed') { + completed = true; + const histUrl = `${SONARR_HOST}/api/v3/history/series?seriesId=${seriesId}&pageSize=50&apikey=${SONARR_API_KEY}`; + let grabbed = 0; + try { + const history = await fetchWithTimeout(histUrl, 8000); + const records = history.records || history || []; + const since = Date.now() - 120000; + grabbed = (Array.isArray(records) ? records : []).filter( + r => r.eventType === 'grabbed' && new Date(r.date).getTime() > since, + ).length; + } catch {} + if (grabbed > 0) { + advancePipeline(pipelineKey, 'grabbed', { progress: null }); + if (logId) addLogStep(logId, `Grabbed ${grabbed} release(s) for "${seriesTitle}"`, 'success'); + } else { + advancePipeline(pipelineKey, 'searching', { canRetry: true }); + if (logId) addLogStep(logId, 'Search completed — no releases grabbed', 'pending'); + } + break; + } else if (cmd.status === 'failed') { + if (logId) addLogStep(logId, `Search command failed: ${cmd.errorMessage || 'unknown'}`, 'error'); + advancePipeline(pipelineKey, 'searching', { canRetry: true }); + break; + } + } catch {} + } + if (!completed) { + advancePipeline(pipelineKey, 'searching', { canRetry: true }); + } + } catch (err) { + console.error('watchSonarrSearch error:', err.message); + } +} diff --git a/backend/src/library/index.js b/backend/src/library/index.js new file mode 100644 index 0000000..acac084 --- /dev/null +++ b/backend/src/library/index.js @@ -0,0 +1 @@ +export { refreshLibraryCache, startLibraryCacheRefresh } from './cacheRefresh.js'; diff --git a/backend/src/library/routes/add.js b/backend/src/library/routes/add.js new file mode 100644 index 0000000..3bb0bb6 --- /dev/null +++ b/backend/src/library/routes/add.js @@ -0,0 +1,558 @@ +import { Router } from 'express'; +import { fetchWithTimeout, pickArrImageUrl } from '../../utils.js'; +import { logActivity, updateLogEntry, addLogStep, addPipelineItem, advancePipeline } from '../../state.js'; +import { searchAndDownloadSlskd } from '../../routes/slskd.js'; +import { refreshLibraryCache } from '../cacheRefresh.js'; +import { watchRadarrSearch, watchSonarrSearch } from '../helpers/watchSearch.js'; +import { normalizeAlbumTitle } from '../helpers/musicTitle.js'; +import { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY, SLSKD_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.post('/add/movie', async (req, res) => { + if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); + const { tmdbId, qualityProfileId, rootFolderPath, minimumAvailability, monitored, searchForMovie } = req.body; + if (!tmdbId) return res.status(400).json({ error: 'Missing tmdbId' }); + const logId = logActivity('add', `Adding movie (tmdb:${tmdbId}) to Radarr...`, { tmdbId }, 'pending', { + service: 'radarr', + tmdbId, + }); + try { + const lookup = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/movie/lookup/tmdb?tmdbId=${tmdbId}&apikey=${RADARR_API_KEY}`, + ); + const movieData = Array.isArray(lookup) ? lookup[0] : lookup; + if (!movieData) throw new Error('Movie not found'); + + const resp = await fetch(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...movieData, + qualityProfileId: qualityProfileId || 1, + rootFolderPath: rootFolderPath || '/movies', + minimumAvailability: minimumAvailability || 'released', + monitored: monitored !== false, + addOptions: { searchForMovie: false }, + }), + }); + + let result; + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + const errMsg = err[0]?.errorMessage || err.message || `HTTP ${resp.status}`; + if (errMsg.toLowerCase().includes('already been added') || errMsg.toLowerCase().includes('already exists')) { + const allMovies = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`); + result = allMovies.find(m => m.tmdbId === Number(tmdbId)); + if (!result?.id) throw new Error(errMsg); + if (!result.monitored || (qualityProfileId && result.qualityProfileId !== qualityProfileId)) { + await fetch(`${RADARR_HOST}/api/v3/movie/${result.id}?apikey=${RADARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...result, + monitored: true, + qualityProfileId: qualityProfileId || result.qualityProfileId, + }), + }).catch(() => {}); + } + updateLogEntry(logId, { + status: 'info', + message: `"${result.title}" already in Radarr — re-enabled monitoring and triggering search`, + context: { service: 'radarr', title: result.title, movieId: result.id }, + }); + } else { + throw new Error(errMsg); + } + } else { + result = await resp.json(); + updateLogEntry(logId, { + status: 'success', + message: `Added "${result.title}" to Radarr, searching for downloads`, + context: { service: 'radarr', title: result.title, movieId: result.id }, + }); + refreshLibraryCache(); + } + + const posterUrl = pickArrImageUrl(movieData.images || [], 'poster', 'radarr'); + const pipelineKey = `radarr-${result.id}-${Date.now()}`; + addPipelineItem(pipelineKey, { + service: 'radarr', + title: result.title, + subtitle: 'Movie', + posterUrl, + logId, + movieId: result.id, + retryId: result.id, + }); + advancePipeline(pipelineKey, 'searching'); + if (searchForMovie !== false) { + fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'MoviesSearch', movieIds: [result.id] }), + }) + .then(r => r.json()) + .then(cmd => { + if (cmd?.id) + watchRadarrSearch(pipelineKey, cmd.id, result.id, logId, result.title).catch(e => + console.error('watchRadarrSearch add:', e.message), + ); + }) + .catch(() => {}); + } + + res.json({ success: true, id: result.id, title: result.title }); + } catch (err) { + updateLogEntry(logId, { status: 'error', message: `Failed to add movie: ${err.message}` }); + console.error('Add movie error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + + +router.post('/add/series', async (req, res) => { + if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); + const { + tvdbId, + qualityProfileId, + rootFolderPath, + seriesType, + monitored, + monitorOption, + searchForMissingEpisodes, + selectedSeasons, + } = req.body; + if (!tvdbId) return res.status(400).json({ error: 'Missing tvdbId' }); + const logId = logActivity('add', `Adding series (tvdb:${tvdbId}) to Sonarr...`, { tvdbId }, 'pending', { + service: 'sonarr', + tvdbId, + }); + try { + const lookupResults = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/series/lookup?term=tvdb:${tvdbId}&apikey=${SONARR_API_KEY}`, + ); + const seriesData = Array.isArray(lookupResults) ? lookupResults[0] : lookupResults; + if (!seriesData) throw new Error('Series not found'); + + if (selectedSeasons && seriesData.seasons) { + seriesData.seasons = seriesData.seasons.map(s => ({ + ...s, + monitored: selectedSeasons.includes(s.seasonNumber), + })); + } + + const resp = await fetch(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...seriesData, + qualityProfileId: qualityProfileId || 1, + rootFolderPath: rootFolderPath || '/tv', + seriesType: seriesType || 'standard', + monitored: monitored !== false, + seasonFolder: true, + addOptions: { + monitor: selectedSeasons ? 'none' : monitorOption || 'all', + searchForMissingEpisodes: searchForMissingEpisodes !== false, + searchForCutoffUnmetEpisodes: false, + }, + }), + }); + + let result; + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + const errMsg = err[0]?.errorMessage || err.message || `HTTP ${resp.status}`; + if (errMsg.toLowerCase().includes('already been added') || errMsg.toLowerCase().includes('already exists')) { + const allSeries = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`); + result = allSeries.find(s => s.tvdbId === Number(tvdbId)); + if (!result?.id) throw new Error(errMsg); + const updatedSeries = { + ...result, + monitored: true, + seasons: + result.seasons?.map(s => ({ + ...s, + monitored: selectedSeasons ? selectedSeasons.includes(s.seasonNumber) : s.monitored, + })) || [], + }; + await fetch(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedSeries), + }).catch(() => {}); + const seasonLabel = selectedSeasons ? ` (seasons ${selectedSeasons.join(', ')})` : ''; + updateLogEntry(logId, { + status: 'info', + message: `"${result.title}"${seasonLabel} already in Sonarr — re-enabled monitoring and triggering search`, + context: { service: 'sonarr', title: result.title, seriesId: result.id, seasons: selectedSeasons }, + }); + } else { + throw new Error(errMsg); + } + } else { + result = await resp.json(); + const seasonLabel = selectedSeasons ? ` (seasons ${selectedSeasons.join(', ')})` : ''; + updateLogEntry(logId, { + status: 'success', + message: `Added "${result.title}"${seasonLabel} to Sonarr, searching for downloads`, + context: { service: 'sonarr', title: result.title, seriesId: result.id, seasons: selectedSeasons }, + }); + refreshLibraryCache(); + } + + const seriesPoster = pickArrImageUrl(seriesData.images || [], 'poster', 'sonarr'); + if (selectedSeasons?.length) { + // Sonarr does not reliably persist per-season monitoring during create, so confirm it with a follow-up PUT. + try { + const currentSeries = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, + ); + const selectedSeasonSet = new Set(selectedSeasons.map(Number)); + const updatedSeries = { + ...currentSeries, + monitored: true, + seasons: (currentSeries.seasons || []).map(season => ({ + ...season, + monitored: selectedSeasonSet.has(season.seasonNumber), + })), + }; + const monitorResp = await fetch(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedSeries), + }); + if (monitorResp.ok) { + addLogStep(logId, `Enabled Sonarr monitoring for seasons ${selectedSeasons.join(', ')}`, 'info'); + } else { + addLogStep( + logId, + `Sonarr add completed, but season monitoring PUT returned HTTP ${monitorResp.status}`, + 'warning', + ); + } + } catch (err) { + addLogStep(logId, `Failed to confirm Sonarr season monitoring: ${err.message}`, 'warning'); + } + } + const pipelineKey = `sonarr-${result.id}-add-${Date.now()}`; + const subtitle = selectedSeasons ? `Seasons ${selectedSeasons.join(', ')}` : 'All seasons'; + addPipelineItem(pipelineKey, { + service: 'sonarr', + title: result.title, + subtitle, + posterUrl: seriesPoster, + logId, + seriesId: result.id, + seasonNumbers: selectedSeasons || null, + retryId: result.id, + }); + advancePipeline(pipelineKey, 'searching'); + const singleSeasonSearch = selectedSeasons?.length === 1; + const cmdBody = singleSeasonSearch + ? { name: 'SeasonSearch', seriesId: result.id, seasonNumber: selectedSeasons[0] } + : { name: 'SeriesSearch', seriesId: result.id }; + fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cmdBody), + }) + .then(r => r.json()) + .then(cmd => { + if (cmd?.id) + watchSonarrSearch( + pipelineKey, + cmd.id, + result.id, + singleSeasonSearch ? selectedSeasons[0] : null, + logId, + result.title, + ).catch(e => console.error('watchSonarrSearch add:', e.message)); + }) + .catch(() => {}); + + res.json({ success: true, id: result.id, title: result.title }); + } catch (err) { + updateLogEntry(logId, { status: 'error', message: `Failed to add series: ${err.message}` }); + console.error('Add series error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +router.post('/add/music', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + const { + foreignArtistId, + artistName, + qualityProfileId, + metadataProfileId, + rootFolderPath, + monitored, + monitorOption, + searchForMissingAlbums, + selectedAlbums, + selectedAlbumTitles, + } = req.body; + if (!foreignArtistId) return res.status(400).json({ error: 'Missing foreignArtistId' }); + + const hasAlbumSelection = selectedAlbumTitles && selectedAlbumTitles.length > 0; + const logId = logActivity('add', `Adding artist "${artistName}" to Lidarr...`, { foreignArtistId }, 'pending', { + service: 'lidarr', + artistName, + foreignArtistId, + }); + + try { + addLogStep( + logId, + `Adding "${artistName}" to Lidarr${hasAlbumSelection ? ` (${selectedAlbumTitles.length} albums selected)` : ' (all albums)'}`, + 'pending', + ); + + const addPayload = { + foreignArtistId, + artistName: artistName || '', + qualityProfileId: qualityProfileId || 1, + metadataProfileId: metadataProfileId || 1, + rootFolderPath: rootFolderPath || '/music', + monitored: true, + monitorNewItems: 'all', + addOptions: { + // For curated album picks, import the artist first and opt matching albums into monitoring afterward. + monitor: hasAlbumSelection ? 'none' : monitorOption || 'all', + searchForMissingAlbums: hasAlbumSelection ? false : searchForMissingAlbums !== false, + }, + }; + + const resp = await fetch(`${LIDARR_HOST}/api/v1/artist?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(addPayload), + }); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err[0]?.errorMessage || err.message || `HTTP ${resp.status}`); + } + const addResult = await resp.json(); + addLogStep(logId, `Artist "${addResult.artistName}" added to Lidarr (ID: ${addResult.id})`, 'success'); + + let albums = []; + addLogStep(logId, 'Waiting for Lidarr to import album metadata...', 'pending'); + for (let attempt = 0; attempt < 15; attempt++) { + await new Promise(r => setTimeout(r, 2000)); + try { + albums = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/album?artistId=${addResult.id}&apikey=${LIDARR_API_KEY}`, + 10000, + ); + } catch (e) { + console.log(`Album fetch attempt ${attempt + 1} error: ${e.message}`); + } + if (albums.length > 0) break; + } + if (albums.length > 0) { + addLogStep(logId, `Lidarr imported ${albums.length} album(s)`, 'success'); + } else { + addLogStep( + logId, + 'Warning: Lidarr returned 0 albums after 30s — artist may have no MusicBrainz releases', + 'warning', + ); + } + + const albumsToSearch = []; + let unmatchedAlbumTitles = []; + + if (hasAlbumSelection && albums.length > 0) { + const normalizedSelected = selectedAlbumTitles.map(t => ({ original: t, norm: normalizeAlbumTitle(t) })); + + const matched = []; + const matchedOriginals = new Set(); + + const albumsWithMatches = albums + .map(album => { + const albumNorm = normalizeAlbumTitle(album.title || ''); + const matchEntry = + normalizedSelected.find(sel => sel.norm === albumNorm) || + normalizedSelected.find(sel => { + if (sel.norm.length < 3 || albumNorm.length < 3) return false; + const shorter = sel.norm.length <= albumNorm.length ? sel.norm : albumNorm; + const longer = sel.norm.length > albumNorm.length ? sel.norm : albumNorm; + if (shorter.length / longer.length < 0.6) return false; + return longer.includes(shorter); + }); + return { album, matchEntry }; + }) + .filter(({ matchEntry }) => matchEntry != null); + + await Promise.all( + albumsWithMatches.map(async ({ album, matchEntry }) => { + album.monitored = true; + matchedOriginals.add(matchEntry.original); + try { + await fetch(`${LIDARR_HOST}/api/v1/album/${album.id}?apikey=${LIDARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(album), + }); + matched.push(album.title); + albumsToSearch.push({ id: album.id, title: album.title }); + } catch (e) { + addLogStep(logId, `Failed to monitor "${album.title}": ${e.message}`, 'error'); + } + }), + ); + + const unmatchedSelected = selectedAlbumTitles.filter(t => !matchedOriginals.has(t)); + + if (matched.length > 0) { + addLogStep( + logId, + `Monitoring ${matched.length}/${selectedAlbumTitles.length} albums: ${matched.join(', ')}`, + 'success', + ); + } else { + addLogStep(logId, `Warning: Could not match any selected albums in Lidarr`, 'warning'); + } + if (unmatchedSelected.length > 0) { + unmatchedAlbumTitles = unmatchedSelected; + addLogStep( + logId, + `${unmatchedSelected.length} selected album(s) not found in Lidarr: ${unmatchedSelected.join(', ')}`, + 'warning', + ); + } + } else if (albums.length > 0) { + for (const album of albums) { + if (album.monitored) { + albumsToSearch.push({ id: album.id, title: album.title }); + } + } + addLogStep(logId, `All ${albumsToSearch.length} albums monitored`, 'success'); + } + + if (hasAlbumSelection && albums.length === 0) { + unmatchedAlbumTitles = [...selectedAlbumTitles]; + addLogStep( + logId, + `Lidarr has no album metadata — will search Soulseek directly for ${selectedAlbumTitles.length} album(s)`, + 'pending', + ); + } + + try { + const artistData = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/artist/${addResult.id}?apikey=${LIDARR_API_KEY}`, + 5000, + ); + if (!artistData.monitored) { + artistData.monitored = true; + const putResp = await fetch(`${LIDARR_HOST}/api/v1/artist/${addResult.id}?apikey=${LIDARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(artistData), + }); + if (putResp.ok) { + addLogStep(logId, 'Artist monitoring enabled', 'success'); + } else { + addLogStep(logId, `Warning: Could not enable artist monitoring (HTTP ${putResp.status})`, 'warning'); + } + } + } catch (e) { + addLogStep(logId, `Warning: Could not enable artist monitoring: ${e.message}`, 'warning'); + } + + if (albumsToSearch.length > 0) { + try { + const albumIds = albumsToSearch.map(a => a.id); + await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'AlbumSearch', albumIds }), + }); + addLogStep(logId, `Lidarr search triggered for ${albumIds.length} album(s)`, 'success'); + } catch (e) { + addLogStep(logId, `Lidarr search trigger failed: ${e.message}`, 'error'); + } + } + + if ((albumsToSearch.length > 0 || unmatchedAlbumTitles.length > 0) && SLSKD_API_KEY) { + const resolvedArtistName = addResult.artistName || artistName; + const searchAlbums = [...albumsToSearch, ...unmatchedAlbumTitles.map(t => ({ id: null, title: t }))]; + (async () => { + addLogStep(logId, `Starting Soulseek direct search for ${searchAlbums.length} album(s)...`, 'pending'); + let queued = 0; + let failed = 0; + const failedTitles = []; + + for (const album of searchAlbums) { + try { + const dlResult = await searchAndDownloadSlskd( + resolvedArtistName, + album.title, + logId, + addResult.id, + album.id, + ); + if (dlResult.success) { + queued++; + } else { + failed++; + failedTitles.push(`${album.title} (${dlResult.reason})`); + } + } catch (e) { + failed++; + failedTitles.push(`${album.title} (${e.message})`); + } + if (searchAlbums.indexOf(album) < searchAlbums.length - 1) { + await new Promise(r => setTimeout(r, 1500)); + } + } + + if (queued > 0) { + addLogStep(logId, `Soulseek: Queued downloads for ${queued}/${searchAlbums.length} album(s)`, 'success'); + updateLogEntry(logId, { + status: 'success', + message: `"${resolvedArtistName}" — ${queued} album(s) downloading via Soulseek`, + }); + } else if (failed > 0) { + addLogStep( + logId, + `Soulseek: No downloads queued. Failed: ${failedTitles.join('; ')}. Soularr will retry on next cycle.`, + 'warning', + ); + } + + if (queued > 0) { + console.log('SLSKD downloads queued — auto-import pipeline will handle import when complete'); + } + })().catch(err => { + console.error('SLSKD background search error:', err.message); + addLogStep(logId, `Soulseek background search error: ${err.message}`, 'error'); + }); + } + + const albumInfo = albumsToSearch.length > 0 ? ` (${albumsToSearch.length} albums monitored)` : ''; + updateLogEntry(logId, { + status: 'success', + message: `Added "${addResult.artistName}" to Lidarr${albumInfo}, searching for downloads...`, + }); + refreshLibraryCache(); + res.json({ + success: true, + id: addResult.id, + artistName: addResult.artistName, + albumsMonitored: albumsToSearch.length, + totalAlbums: albums.length, + details: albumsToSearch.map(a => a.title), + }); + } catch (err) { + updateLogEntry(logId, { status: 'error', message: `Failed to add artist: ${err.message}` }); + console.error('Add music error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/library/routes/cacheSearch.js b/backend/src/library/routes/cacheSearch.js new file mode 100644 index 0000000..abe1f8d --- /dev/null +++ b/backend/src/library/routes/cacheSearch.js @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { libraryCache } from '../../state.js'; +import { refreshLibraryCache } from '../cacheRefresh.js'; + +const router = Router(); + +router.get('/library/refresh', async (req, res) => { + await refreshLibraryCache(); + res.json({ ok: true, lastRefresh: libraryCache.lastRefresh }); +}); + +router.get('/library/search', (req, res) => { + const q = (req.query.q || '').toLowerCase().trim(); + const type = req.query.type || 'all'; + const results = { series: [], movies: [], artists: [] }; + + if (type === 'all' || type === 'series') { + results.series = libraryCache.series + .filter(s => !q || s.title.toLowerCase().includes(q)) + .sort((a, b) => a.sortTitle.localeCompare(b.sortTitle)) + .slice(0, 50); + } + if (type === 'all' || type === 'movie') { + results.movies = libraryCache.movies + .filter(m => !q || m.title.toLowerCase().includes(q)) + .sort((a, b) => a.sortTitle.localeCompare(b.sortTitle)) + .slice(0, 50); + } + if (type === 'all' || type === 'music') { + results.artists = libraryCache.artists + .filter(a => !q || a.artistName.toLowerCase().includes(q)) + .sort((a, b) => (a.sortName || a.artistName).localeCompare(b.sortName || b.artistName)) + .slice(0, 50); + } + res.json(results); +}); + +export default router; diff --git a/backend/src/library/routes/delete.js b/backend/src/library/routes/delete.js new file mode 100644 index 0000000..8344c05 --- /dev/null +++ b/backend/src/library/routes/delete.js @@ -0,0 +1,141 @@ +import { Router } from 'express'; +import { fetchWithTimeout, parseArrError } from '../../utils.js'; +import { logActivity, updateLogEntry } from '../../state.js'; +import { refreshLibraryCache } from '../cacheRefresh.js'; +import { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.delete('/delete/movie/:id', async (req, res) => { + if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); + const { id } = req.params; + const deleteFiles = req.query.deleteFiles !== 'false'; + const logId = logActivity( + 'delete', + `Deleting movie ID ${id} from Radarr...`, + { movieId: id, deleteFiles }, + 'pending', + { service: 'radarr' }, + ); + try { + let title = `ID ${id}`; + try { + const info = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie/${id}?apikey=${RADARR_API_KEY}`); + title = info.title || title; + } catch {} + const resp = await fetch( + `${RADARR_HOST}/api/v3/movie/${id}?deleteFiles=${deleteFiles}&addImportExclusion=false&apikey=${RADARR_API_KEY}`, + { + method: 'DELETE', + }, + ); + if (!resp.ok) { + throw new Error(parseArrError(await resp.text(), resp.status)); + } + updateLogEntry(logId, { + status: 'success', + message: `Deleted "${title}" from Radarr${deleteFiles ? ' (files removed)' : ''}`, + context: { service: 'radarr', title }, + }); + refreshLibraryCache(); + res.json({ success: true, title }); + } catch (err) { + updateLogEntry(logId, { status: 'error', message: `Failed to delete movie: ${err.message}` }); + res.status(500).json({ error: err.message }); + } +}); + +router.delete('/delete/series/:id', async (req, res) => { + if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); + const { id } = req.params; + const deleteFiles = req.query.deleteFiles !== 'false'; + const logId = logActivity( + 'delete', + `Deleting series ID ${id} from Sonarr...`, + { seriesId: id, deleteFiles }, + 'pending', + { service: 'sonarr' }, + ); + try { + let title = `ID ${id}`; + try { + const info = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`); + title = info.title || title; + } catch {} + const resp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?deleteFiles=${deleteFiles}&apikey=${SONARR_API_KEY}`, { + method: 'DELETE', + }); + if (!resp.ok) { + throw new Error(parseArrError(await resp.text(), resp.status)); + } + updateLogEntry(logId, { + status: 'success', + message: `Deleted "${title}" from Sonarr${deleteFiles ? ' (files removed)' : ''}`, + context: { service: 'sonarr', title }, + }); + refreshLibraryCache(); + res.json({ success: true, title }); + } catch (err) { + updateLogEntry(logId, { status: 'error', message: `Failed to delete series: ${err.message}` }); + res.status(500).json({ error: err.message }); + } +}); + +router.delete('/delete/album/:id/files', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + try { + const trackFiles = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/trackfile?albumId=${encodeURIComponent(req.params.id)}&apikey=${LIDARR_API_KEY}`, + 8000, + ); + if (!Array.isArray(trackFiles) || trackFiles.length === 0) return res.json({ success: true, deleted: 0 }); + const ids = trackFiles.map(f => f.id); + await fetch(`${LIDARR_HOST}/api/v1/trackfile/bulk?apikey=${LIDARR_API_KEY}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ trackFileIds: ids }), + }); + res.json({ success: true, deleted: ids.length }); + } catch (err) { + console.error('Album file delete error:', err.message); + res.status(502).json({ error: 'Failed to delete album files' }); + } +}); + +router.delete('/delete/music/:id', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + const { id } = req.params; + const deleteFiles = req.query.deleteFiles !== 'false'; + const logId = logActivity( + 'delete', + `Deleting artist ID ${id} from Lidarr...`, + { artistId: id, deleteFiles }, + 'pending', + { service: 'lidarr' }, + ); + try { + let artistName = `ID ${id}`; + try { + const info = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${id}?apikey=${LIDARR_API_KEY}`); + artistName = info.artistName || artistName; + } catch {} + const resp = await fetch(`${LIDARR_HOST}/api/v1/artist/${id}?deleteFiles=${deleteFiles}&apikey=${LIDARR_API_KEY}`, { + method: 'DELETE', + }); + if (!resp.ok) { + throw new Error(parseArrError(await resp.text(), resp.status)); + } + updateLogEntry(logId, { + status: 'success', + message: `Deleted "${artistName}" from Lidarr${deleteFiles ? ' (files removed)' : ''}`, + context: { service: 'lidarr', artistName }, + }); + refreshLibraryCache(); + res.json({ success: true, artistName }); + } catch (err) { + updateLogEntry(logId, { status: 'error', message: `Failed to delete artist: ${err.message}` }); + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/library/routes/lookup.js b/backend/src/library/routes/lookup.js new file mode 100644 index 0000000..caa0983 --- /dev/null +++ b/backend/src/library/routes/lookup.js @@ -0,0 +1,274 @@ +import { Router } from 'express'; +import { fetchWithTimeout, pickImageUrl } from '../../utils.js'; +import { libraryCache, itunesPosterCache } from '../../state.js'; +import { simScore, normalizeLookupArtistName, dedupLookupAlbumTitle } from '../helpers/musicTitle.js'; +import { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.get('/lookup/movie', async (req, res) => { + if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); + const term = req.query.term; + if (!term) return res.status(400).json({ error: 'Missing term' }); + try { + const results = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/movie/lookup?term=${encodeURIComponent(term)}&apikey=${RADARR_API_KEY}`, + 10000, + ); + res.json( + results.slice(0, 20).map(m => ({ + tmdbId: m.tmdbId, + imdbId: m.imdbId, + title: m.title, + year: m.year, + overview: m.overview, + genres: m.genres || [], + ratings: m.ratings, + runtime: m.runtime, + studio: m.studio, + posterUrl: pickImageUrl(m.images, 'poster'), + inLibrary: libraryCache.movies.some(lm => lm.tmdbId === m.tmdbId), + })), + ); + } catch (err) { + console.error('Movie lookup error:', err.message); + res.json([]); + } +}); + +router.get('/lookup/series', async (req, res) => { + if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); + const term = req.query.term; + if (!term) return res.status(400).json({ error: 'Missing term' }); + try { + const results = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/series/lookup?term=${encodeURIComponent(term)}&apikey=${SONARR_API_KEY}`, + 10000, + ); + res.json( + results.slice(0, 20).map(s => ({ + tvdbId: s.tvdbId, + title: s.title, + year: s.year, + overview: s.overview, + genres: s.genres || [], + ratings: s.ratings, + network: s.network, + seasonCount: s.seasonCount, + posterUrl: pickImageUrl(s.images, 'poster'), + inLibrary: libraryCache.series.some(ls => ls.tvdbId === s.tvdbId), + seasons: (s.seasons || []).map(sn => ({ + seasonNumber: sn.seasonNumber, + monitored: sn.monitored, + episodeCount: sn.statistics?.totalEpisodeCount || 0, + })), + })), + ); + } catch (err) { + console.error('Series lookup error:', err.message); + res.json([]); + } +}); + +router.get('/lookup/music', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + const term = req.query.term; + if (!term) return res.status(400).json({ error: 'Missing term' }); + + + try { + const [rawArtists, rawAlbums] = await Promise.all([ + fetchWithTimeout( + `${LIDARR_HOST}/api/v1/artist/lookup?term=${encodeURIComponent(term)}&apikey=${LIDARR_API_KEY}`, + 10000, + ), + fetchWithTimeout( + `${LIDARR_HOST}/api/v1/album/lookup?term=${encodeURIComponent(term)}&apikey=${LIDARR_API_KEY}`, + 10000, + ).catch(() => []), + ]); + + const artistResults = (rawArtists || []).slice(0, 15).map(a => ({ + foreignArtistId: a.foreignArtistId, + artistName: a.artistName, + overview: a.overview, + genres: a.genres || [], + disambiguation: a.disambiguation, + artistType: a.artistType, + posterUrl: pickImageUrl(a.images, 'poster') || pickImageUrl(a.images, 'cover') || null, + inLibrary: libraryCache.artists.some(la => la.foreignArtistId === a.foreignArtistId), + score: simScore(term, a.artistName), + })); + + const albumResults = []; + const singleResults = []; + for (const a of (rawAlbums || []).slice(0, 30)) { + const artist = a.artist || {}; + const isInLibrary = libraryCache.artists.some(la => la.foreignArtistId === artist.foreignArtistId); + const item = { + foreignAlbumId: a.foreignAlbumId, + title: a.title, + disambiguation: a.disambiguation || '', + releaseDate: a.releaseDate, + albumType: a.albumType || 'Album', + genres: a.genres || [], + artistName: artist.artistName || '', + foreignArtistId: artist.foreignArtistId || '', + posterUrl: pickImageUrl(a.images, 'cover') || null, + inLibrary: isInLibrary, + score: simScore(term, a.title), + }; + if (a.albumType === 'Single' || a.albumType === 'EP') singleResults.push(item); + else albumResults.push(item); + } + + const noPoster = artistResults.filter(r => !r.posterUrl).slice(0, 5); + for (const artist of noPoster) { + const hit = itunesPosterCache.get(artist.artistName); + if (hit && Date.now() - hit.ts < 3600000) { + if (hit.url) artist.posterUrl = hit.url; + continue; + } + try { + const itunes = await fetchWithTimeout( + 'https://itunes.apple.com/search?term=' + encodeURIComponent(artist.artistName) + '&entity=album&limit=1', + 5000, + ); + const url = + itunes.results && itunes.results[0] && itunes.results[0].artworkUrl100 + ? itunes.results[0].artworkUrl100.replace('100x100', '600x600') + : null; + itunesPosterCache.set(artist.artistName, { url, ts: Date.now() }); + if (url) artist.posterUrl = url; + } catch {} + await new Promise(r => setTimeout(r, 150)); + } + + const topArtistScore = Math.max(0, ...artistResults.map(r => r.score)); + const topAlbumScore = Math.max(0, ...albumResults.map(r => r.score)); + const topSingleScore = Math.max(0, ...singleResults.map(r => r.score)); + const maxScore = Math.max(topArtistScore, topAlbumScore, topSingleScore); + let topCategory = 'artists'; + if (topAlbumScore === maxScore && topAlbumScore > topArtistScore) topCategory = 'albums'; + else if (topSingleScore === maxScore && topSingleScore > topArtistScore) topCategory = 'singles'; + + artistResults.sort((a, b) => b.score - a.score); + albumResults.sort((a, b) => b.score - a.score); + singleResults.sort((a, b) => b.score - a.score); + + res.json({ artists: artistResults, albums: albumResults, singles: singleResults, topCategory }); + } catch (err) { + console.error('Music lookup error:', err.message); + res.json({ artists: [], albums: [], singles: [], topCategory: 'artists' }); + } +}); + +router.get('/lookup/music/albums', async (req, res) => { + const { artistName, foreignArtistId } = req.query; + if (!artistName) return res.status(400).json({ error: 'Missing artistName' }); + const nameNorm = normalizeLookupArtistName(artistName); + + try { + const itunesArtistSearch = fetchWithTimeout( + `https://itunes.apple.com/search?term=${encodeURIComponent(artistName)}&entity=musicArtist&limit=5`, + 10000, + ).catch(() => ({ results: [] })); + + const mbPromise = foreignArtistId + ? fetchWithTimeout( + `https://musicbrainz.org/ws/2/release-group?artist=${foreignArtistId}&fmt=json&limit=100`, + 10000, + { 'User-Agent': 'arr-dashboard/1.0 (music lookup)' }, + ).catch(() => ({ 'release-groups': [] })) + : Promise.resolve({ 'release-groups': [] }); + + const [itunesArtists, mbData] = await Promise.all([itunesArtistSearch, mbPromise]); + + const itunesArtist = + (itunesArtists.results || []).find(a => normalizeLookupArtistName(a.artistName) === nameNorm) || (itunesArtists.results || [])[0]; + + let itunesAlbums = []; + if (itunesArtist?.artistId) { + try { + const lookupData = await fetchWithTimeout( + `https://itunes.apple.com/lookup?id=${itunesArtist.artistId}&entity=album&limit=200`, + 15000, + ); + itunesAlbums = (lookupData.results || []).filter(r => r.wrapperType === 'collection'); + } catch {} + } + + let searchAlbums = []; + try { + const searchData = await fetchWithTimeout( + `https://itunes.apple.com/search?term=${encodeURIComponent(artistName)}&entity=album&limit=200`, + 10000, + ); + searchAlbums = (searchData.results || []).filter(a => normalizeLookupArtistName(a.artistName) === nameNorm); + } catch {} + + const seenIds = new Set(); + const allItunes = []; + for (const a of [...itunesAlbums, ...searchAlbums]) { + if (!seenIds.has(a.collectionId)) { + seenIds.add(a.collectionId); + const titleLower = a.collectionName.toLowerCase(); + let albumType = 'Album'; + if (titleLower.includes('- single')) albumType = 'Single'; + else if (titleLower.includes('- ep') || titleLower.endsWith(' ep')) albumType = 'EP'; + allItunes.push({ + id: 'itunes-' + a.collectionId, + title: a.collectionName, + releaseDate: a.releaseDate || null, + trackCount: a.trackCount || 0, + albumType, + coverUrl: a.artworkUrl100 ? a.artworkUrl100.replace('100x100', '600x600') : null, + source: 'itunes', + }); + } + } + + const mbGroups = (mbData['release-groups'] || []).map(g => { + const ptype = (g['primary-type'] || '').toLowerCase(); + const stypes = (g['secondary-types'] || []).map(s => s.toLowerCase()); + let albumType = 'Album'; + if (ptype === 'single' || stypes.includes('single')) albumType = 'Single'; + else if (ptype === 'ep' || stypes.includes('ep')) albumType = 'EP'; + return { + id: 'mb-' + g.id, + title: g.title, + releaseDate: g['first-release-date'] ? g['first-release-date'] + 'T00:00:00Z' : null, + trackCount: 0, + albumType, + coverUrl: `https://coverartarchive.org/release-group/${g.id}/front-250`, + source: 'musicbrainz', + }; + }); + + // Prefer iTunes metadata when available, then backfill unique MusicBrainz release groups that iTunes misses. + const seenTitles = new Set(allItunes.map(a => dedupLookupAlbumTitle(a.title))); + const merged = [...allItunes]; + for (const mb of mbGroups) { + const mbTitleNorm = dedupLookupAlbumTitle(mb.title); + if (!seenTitles.has(mbTitleNorm)) { + seenTitles.add(mbTitleNorm); + merged.push(mb); + } + } + + const typeOrder = { Album: 0, EP: 1, Single: 2 }; + merged.sort((a, b) => { + const ta = typeOrder[a.albumType] ?? 1; + const tb = typeOrder[b.albumType] ?? 1; + if (ta !== tb) return ta - tb; + return (b.releaseDate || '').localeCompare(a.releaseDate || ''); + }); + + res.json(merged); + } catch (err) { + console.error('Album lookup error:', err.message); + res.status(502).json({ error: 'Failed to fetch albums' }); + } +}); + +export default router; diff --git a/backend/src/library/routes/movie.js b/backend/src/library/routes/movie.js new file mode 100644 index 0000000..0f454ee --- /dev/null +++ b/backend/src/library/routes/movie.js @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { fetchWithTimeout } from '../../utils.js'; +import { RADARR_HOST, RADARR_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.get('/library/movie/:id/file', async (req, res) => { + if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); + try { + const files = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/moviefile?movieId=${encodeURIComponent(req.params.id)}&apikey=${RADARR_API_KEY}`, + 8000, + ); + const f = Array.isArray(files) ? files[0] : files; + if (!f) return res.json(null); + const mi = f.mediaInfo || {}; + res.json({ + path: f.path || null, + relativePath: f.relativePath || null, + size: f.size || 0, + quality: f.quality?.quality?.name || null, + videoCodec: mi.videoCodec || null, + videoFps: mi.videoFps || null, + resolution: mi.resolution || null, + audioCodec: mi.audioCodec || null, + audioChannels: mi.audioChannels || null, + audioLanguages: mi.audioLanguages || null, + runTime: mi.runTime || null, + subtitles: mi.subtitles || null, + videoBitDepth: mi.videoBitDepth || null, + dynamicRange: mi.videoDynamicRangeType || null, + }); + } catch (err) { + console.error('Movie file info error:', err.message); + res.status(502).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/library/routes/music.js b/backend/src/library/routes/music.js new file mode 100644 index 0000000..b60ecad --- /dev/null +++ b/backend/src/library/routes/music.js @@ -0,0 +1,306 @@ +import { Router } from 'express'; +import { execFileSync } from 'child_process'; +import { statSync } from 'fs'; +import { fetchWithTimeout, pickImageUrl } from '../../utils.js'; +import { logActivity, updateLogEntry, addLogStep } from '../../state.js'; +import { searchAndDownloadSlskd } from '../../routes/slskd.js'; +import { refreshLibraryCache } from '../cacheRefresh.js'; +import { ensureExtendedMetadataProfile } from '../helpers/metadataProfile.js'; +import { normalizeAlbumTitle } from '../helpers/musicTitle.js'; +import { LIDARR_HOST, LIDARR_API_KEY, SLSKD_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.get('/library/artists/:id/files', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + try { + const artist = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(req.params.id)}?apikey=${LIDARR_API_KEY}`, + 10000, + ); + const artistPath = artist.path; + if (!artistPath) return res.json({ path: null, folders: [] }); + + const hostPath = artistPath.replace(/^\/data\//, '/hostdocker/'); + let folders = []; + try { + const lsOut = execFileSync('find', [hostPath, '-type', 'f'], { encoding: 'utf8', timeout: 10000 }); + const files = lsOut.trim().split('\n').filter(Boolean); + const grouped = {}; + for (const f of files) { + const rel = f.replace(hostPath + '/', ''); + const parts = rel.split('/'); + const folder = parts.length > 1 ? parts[0] : '.'; + if (!grouped[folder]) grouped[folder] = []; + grouped[folder].push({ + name: parts[parts.length - 1], + path: rel, + size: (() => { + try { + return statSync(f).size; + } catch { + return 0; + } + })(), + }); + } + folders = Object.entries(grouped) + .map(([name, files]) => ({ + name, + fileCount: files.length, + totalSize: files.reduce((s, f) => s + f.size, 0), + files, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch (e) { + console.error('File tree error:', e.message); + } + res.json({ path: artistPath, folders }); + } catch (err) { + console.error('Artist file tree error:', err.message); + res.status(502).json({ error: 'Failed to fetch file tree' }); + } +}); + +router.get('/library/artists/:id/albums', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + try { + const [albums, artist] = await Promise.all([ + fetchWithTimeout( + `${LIDARR_HOST}/api/v1/album?artistId=${encodeURIComponent(req.params.id)}&apikey=${LIDARR_API_KEY}`, + 10000, + ), + fetchWithTimeout( + `${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(req.params.id)}?apikey=${LIDARR_API_KEY}`, + 10000, + ).catch(() => null), + ]); + const formatted = albums.map(a => ({ + id: a.id, + title: a.title, + releaseDate: a.releaseDate, + genres: a.genres || [], + overview: a.overview, + monitored: a.monitored, + albumType: a.albumType || 'Album', + coverUrl: pickImageUrl(a.images, 'cover'), + trackCount: a.statistics?.trackCount || 0, + trackFileCount: a.statistics?.trackFileCount || 0, + sizeOnDisk: a.statistics?.sizeOnDisk || 0, + percentOfTracks: a.statistics?.percentOfTracks || 0, + })); + formatted.sort((a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)); + res.json({ + albums: formatted, + artist: artist + ? { + id: artist.id, + foreignArtistId: artist.foreignArtistId, + artistName: artist.artistName, + metadataProfileId: artist.metadataProfileId, + } + : null, + }); + } catch (err) { + console.error('Album fetch error:', err.message); + res.status(502).json({ error: 'Failed to fetch albums' }); + } +}); + +router.post('/library/artists/:id/albums/add', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + const { selectedAlbumTitles } = req.body || {}; + if (!Array.isArray(selectedAlbumTitles) || selectedAlbumTitles.length === 0) { + return res.status(400).json({ error: 'selectedAlbumTitles required' }); + } + const artistId = req.params.id; + let logId = null; + + try { + const artist = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(artistId)}?apikey=${LIDARR_API_KEY}`, + 10000, + ); + if (!artist?.id) return res.status(404).json({ error: 'Artist not found' }); + + logId = logActivity( + 'add', + `Adding ${selectedAlbumTitles.length} extra album(s) to "${artist.artistName}"...`, + { artistId: artist.id, count: selectedAlbumTitles.length }, + 'pending', + { service: 'lidarr', artistName: artist.artistName }, + ); + + let profileBumped = false; + try { + const extId = await ensureExtendedMetadataProfile(); + if (artist.metadataProfileId !== extId) { + artist.metadataProfileId = extId; + const putResp = await fetch(`${LIDARR_HOST}/api/v1/artist/${artist.id}?apikey=${LIDARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(artist), + }); + if (putResp.ok) { + profileBumped = true; + addLogStep(logId, 'Switched artist to extended metadata profile (Album+EP+Single)', 'success'); + } else addLogStep(logId, `Warning: could not switch metadata profile (HTTP ${putResp.status})`, 'warning'); + } + } catch (e) { + addLogStep(logId, `Metadata profile setup failed: ${e.message}`, 'warning'); + } + + if (profileBumped) { + try { + const r = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'RefreshArtist', artistId: artist.id }), + }); + if (!r.ok) addLogStep(logId, `Warning: RefreshArtist HTTP ${r.status}`, 'warning'); + else addLogStep(logId, 'Refreshing artist metadata...', 'pending'); + } catch (e) { + addLogStep(logId, `RefreshArtist failed: ${e.message}`, 'warning'); + } + } + + const targetNorms = selectedAlbumTitles.map(t => ({ original: t, norm: normalizeAlbumTitle(t) })); + + let albums = []; + const pollCount = profileBumped ? 15 : 1; + for (let attempt = 0; attempt < pollCount; attempt++) { + try { + albums = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/album?artistId=${artist.id}&apikey=${LIDARR_API_KEY}`, + 10000, + ); + } catch (e) {} + const matchedAll = targetNorms.every(sel => + albums.some(a => { + const an = normalizeAlbumTitle(a.title); + if (an === sel.norm) return true; + if (sel.norm.length < 3 || an.length < 3) return false; + const shorter = sel.norm.length <= an.length ? sel.norm : an; + const longer = sel.norm.length > an.length ? sel.norm : an; + if (shorter.length / longer.length < 0.6) return false; + return longer.includes(shorter); + }), + ); + if (matchedAll) break; + if (attempt < pollCount - 1) await new Promise(r => setTimeout(r, 2000)); + } + addLogStep(logId, `Lidarr now reports ${albums.length} total album(s) for artist`, 'pending'); + + const matched = []; + const matchedOriginals = new Set(); + const albumsWithMatches = albums + .map(album => { + const albumNorm = normalizeAlbumTitle(album.title || ''); + const matchEntry = + targetNorms.find(sel => sel.norm === albumNorm) || + targetNorms.find(sel => { + if (sel.norm.length < 3 || albumNorm.length < 3) return false; + const shorter = sel.norm.length <= albumNorm.length ? sel.norm : albumNorm; + const longer = sel.norm.length > albumNorm.length ? sel.norm : albumNorm; + if (shorter.length / longer.length < 0.6) return false; + return longer.includes(shorter); + }); + return { album, matchEntry }; + }) + .filter(({ matchEntry }) => matchEntry != null); + + const albumsToSearch = []; + await Promise.all( + albumsWithMatches.map(async ({ album, matchEntry }) => { + matchedOriginals.add(matchEntry.original); + if (!album.monitored) { + album.monitored = true; + try { + await fetch(`${LIDARR_HOST}/api/v1/album/${album.id}?apikey=${LIDARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(album), + }); + } catch (e) { + addLogStep(logId, `Failed to monitor "${album.title}": ${e.message}`, 'error'); + return; + } + } + matched.push(album.title); + albumsToSearch.push({ id: album.id, title: album.title }); + }), + ); + + const unmatchedAlbumTitles = selectedAlbumTitles.filter(t => !matchedOriginals.has(t)); + if (matched.length > 0) + addLogStep(logId, `Monitoring ${matched.length} album(s): ${matched.join(', ')}`, 'success'); + if (unmatchedAlbumTitles.length > 0) + addLogStep( + logId, + `${unmatchedAlbumTitles.length} not in Lidarr (will try Soulseek): ${unmatchedAlbumTitles.join(', ')}`, + 'warning', + ); + + if (albumsToSearch.length > 0) { + try { + const r = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'AlbumSearch', albumIds: albumsToSearch.map(a => a.id) }), + }); + if (!r.ok) { + const txt = await r.text().catch(() => ''); + addLogStep(logId, `Lidarr search HTTP ${r.status} ${txt.substring(0, 100)}`, 'error'); + } else { + addLogStep(logId, `Lidarr search triggered for ${albumsToSearch.length} album(s)`, 'success'); + } + } catch (e) { + addLogStep(logId, `Lidarr search failed: ${e.message}`, 'error'); + } + } + + if (SLSKD_API_KEY) { + const searchAlbums = [...albumsToSearch, ...unmatchedAlbumTitles.map(t => ({ id: null, title: t }))]; + const resolvedArtistName = artist.artistName; + (async () => { + addLogStep(logId, `Starting Soulseek search for ${searchAlbums.length} album(s)...`, 'pending'); + let queued = 0; + let failed = 0; + for (const album of searchAlbums) { + try { + const dl = await searchAndDownloadSlskd(resolvedArtistName, album.title, logId, artist.id, album.id); + if (dl.success) queued++; + else failed++; + } catch { + failed++; + } + await new Promise(r => setTimeout(r, 1500)); + } + if (queued > 0) { + updateLogEntry(logId, { + status: 'success', + message: `"${resolvedArtistName}" — ${queued} extra album(s) downloading`, + }); + } else { + addLogStep(logId, `Soulseek queued nothing (failed: ${failed})`, 'warning'); + } + })().catch(err => { + addLogStep(logId, `Soulseek background error: ${err.message}`, 'error'); + }); + } + + refreshLibraryCache(); + res.json({ + success: true, + monitored: albumsToSearch.length, + unmatched: unmatchedAlbumTitles, + profileBumped, + }); + } catch (err) { + if (logId) updateLogEntry(logId, { status: 'error', message: `Add extra albums failed: ${err.message}` }); + console.error('Add extra albums error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/backend/src/library/routes/profiles.js b/backend/src/library/routes/profiles.js new file mode 100644 index 0000000..046f614 --- /dev/null +++ b/backend/src/library/routes/profiles.js @@ -0,0 +1,89 @@ +import { Router } from 'express'; +import { fetchWithTimeout } from '../../utils.js'; +import { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.get('/profiles/movie', async (req, res) => { + if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); + try { + const [profiles, rootFolders] = await Promise.all([ + fetchWithTimeout(`${RADARR_HOST}/api/v3/qualityprofile?apikey=${RADARR_API_KEY}`), + fetchWithTimeout(`${RADARR_HOST}/api/v3/rootfolder?apikey=${RADARR_API_KEY}`), + ]); + res.json({ + qualityProfiles: profiles.map(p => ({ id: p.id, name: p.name })), + rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), + minimumAvailabilities: [ + { value: 'announced', label: 'Announced' }, + { value: 'inCinemas', label: 'In Cinemas' }, + { value: 'released', label: 'Released' }, + ], + }); + } catch (err) { + console.error('Movie profiles error:', err.message); + res.status(502).json({ error: 'Failed to fetch profiles' }); + } +}); + +router.get('/profiles/series', async (req, res) => { + if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); + try { + const [profiles, rootFolders] = await Promise.all([ + fetchWithTimeout(`${SONARR_HOST}/api/v3/qualityprofile?apikey=${SONARR_API_KEY}`), + fetchWithTimeout(`${SONARR_HOST}/api/v3/rootfolder?apikey=${SONARR_API_KEY}`), + ]); + res.json({ + qualityProfiles: profiles.map(p => ({ id: p.id, name: p.name })), + rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), + seriesTypes: [ + { value: 'standard', label: 'Standard' }, + { value: 'daily', label: 'Daily' }, + { value: 'anime', label: 'Anime' }, + ], + monitorOptions: [ + { value: 'all', label: 'All Episodes' }, + { value: 'future', label: 'Future Episodes' }, + { value: 'missing', label: 'Missing Episodes' }, + { value: 'existing', label: 'Existing Episodes' }, + { value: 'pilot', label: 'Pilot Only' }, + { value: 'firstSeason', label: 'First Season' }, + { value: 'lastSeason', label: 'Last Season' }, + { value: 'none', label: 'None' }, + ], + }); + } catch (err) { + console.error('Series profiles error:', err.message); + res.status(502).json({ error: 'Failed to fetch profiles' }); + } +}); + +router.get('/profiles/music', async (req, res) => { + if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); + try { + const [qualityProfiles, metadataProfiles, rootFolders] = await Promise.all([ + fetchWithTimeout(`${LIDARR_HOST}/api/v1/qualityprofile?apikey=${LIDARR_API_KEY}`), + fetchWithTimeout(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`), + fetchWithTimeout(`${LIDARR_HOST}/api/v1/rootfolder?apikey=${LIDARR_API_KEY}`), + ]); + res.json({ + qualityProfiles: qualityProfiles.map(p => ({ id: p.id, name: p.name })), + metadataProfiles: metadataProfiles.map(p => ({ id: p.id, name: p.name })), + rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), + monitorOptions: [ + { value: 'all', label: 'All Albums' }, + { value: 'future', label: 'Future Albums' }, + { value: 'missing', label: 'Missing Albums' }, + { value: 'existing', label: 'Existing Albums' }, + { value: 'first', label: 'First Album' }, + { value: 'latest', label: 'Latest Album' }, + { value: 'none', label: 'None' }, + ], + }); + } catch (err) { + console.error('Music profiles error:', err.message); + res.status(502).json({ error: 'Failed to fetch profiles' }); + } +}); + +export default router; diff --git a/backend/src/library/routes/tv.js b/backend/src/library/routes/tv.js new file mode 100644 index 0000000..84a5adc --- /dev/null +++ b/backend/src/library/routes/tv.js @@ -0,0 +1,92 @@ +import { Router } from 'express'; +import { fetchWithTimeout } from '../../utils.js'; +import { SONARR_HOST, SONARR_API_KEY } from '../arrConfig.js'; + +const router = Router(); + +router.get('/library/series/:id/episodes', async (req, res) => { + if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); + try { + const sid = encodeURIComponent(req.params.id); + const [episodes, episodeFiles] = await Promise.all([ + fetchWithTimeout(`${SONARR_HOST}/api/v3/episode?seriesId=${sid}&apikey=${SONARR_API_KEY}`, 10000), + fetchWithTimeout(`${SONARR_HOST}/api/v3/episodefile?seriesId=${sid}&apikey=${SONARR_API_KEY}`, 10000), + ]); + const fileMap = {}; + (Array.isArray(episodeFiles) ? episodeFiles : []).forEach(f => { + fileMap[f.id] = f; + }); + const seasons = {}; + episodes.forEach(ep => { + const sn = ep.seasonNumber; + if (!seasons[sn]) seasons[sn] = []; + const ef = ep.episodeFileId ? fileMap[ep.episodeFileId] : null; + const mi = ef?.mediaInfo || {}; + seasons[sn].push({ + id: ep.id, + episodeNumber: ep.episodeNumber, + title: ep.title, + airDate: ep.airDateUtc, + hasFile: ep.hasFile, + monitored: ep.monitored, + overview: ep.overview, + runtime: ep.runtime || null, + quality: ef?.quality?.quality?.name || null, + size: ef?.size || 0, + filePath: ef?.path || null, + relativePath: ef?.relativePath || null, + videoCodec: mi.videoCodec || null, + videoFps: mi.videoFps || null, + resolution: mi.resolution || null, + audioCodec: mi.audioCodec || null, + audioChannels: mi.audioChannels || null, + audioLanguages: mi.audioLanguages || null, + runTime: mi.runTime || null, + subtitles: mi.subtitles || null, + dynamicRange: mi.videoDynamicRangeType || null, + imageUrl: null, + }); + }); + Object.values(seasons).forEach(eps => eps.sort((a, b) => a.episodeNumber - b.episodeNumber)); + + // Sonarr only exposes screenshot URLs on the per-episode resource, so enrich file-backed episodes in a second pass. + const epIdsNeedingImages = []; + for (const eps of Object.values(seasons)) { + for (const ep of eps) { + if (ep.hasFile && ep.id) epIdsNeedingImages.push(ep.id); + } + } + if (epIdsNeedingImages.length > 0) { + const imageMap = {}; + const BATCH_SIZE = 15; + for (let i = 0; i < epIdsNeedingImages.length; i += BATCH_SIZE) { + const batch = epIdsNeedingImages.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(eid => + fetchWithTimeout(SONARR_HOST + '/api/v3/episode/' + eid + '?apikey=' + SONARR_API_KEY, 5000), + ), + ); + results.forEach((result, j) => { + if (result.status === 'fulfilled' && result.value && result.value.images && result.value.images.length > 0) { + const sshot = result.value.images.find(img => img.coverType === 'screenshot'); + if (sshot && sshot.remoteUrl) { + imageMap[batch[j]] = '/api/poster?url=' + encodeURIComponent(sshot.remoteUrl); + } + } + }); + } + for (const eps of Object.values(seasons)) { + for (const ep of eps) { + if (imageMap[ep.id]) ep.imageUrl = imageMap[ep.id]; + } + } + } + + res.json({ seasons }); + } catch (err) { + console.error('Episode fetch error:', err.message); + res.status(502).json({ error: 'Failed to fetch episodes' }); + } +}); + +export default router; diff --git a/backend/src/middleware.js b/backend/src/middleware.js index 7fcb3cf..b3901da 100644 --- a/backend/src/middleware.js +++ b/backend/src/middleware.js @@ -1,20 +1,13 @@ import express from 'express'; import { AppError } from './errors.js'; import { CONFIG } from './config.js'; -import { clearAllIntervals, persistActivityLog, saveBwLifetime, clearFakeTorrentCheckInterval } from './state.js'; - -const DEFAULT_ALLOWED_HEADERS = ['Content-Type', 'X-Api-Key']; - -function redactLogValue(value) { - if (Array.isArray(value)) return value.map(redactLogValue); - if (!value || typeof value !== 'object') return value; - return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => { - if (/pass|password|secret|token|api[_-]?key|authorization/i.test(key)) { - return [key, '[redacted]']; - } - return [key, redactLogValue(nestedValue)]; - })); -} +import { + clearAllIntervals, + persistActivityLog, + registerInterval, + saveBwLifetime, + clearFakeTorrentCheckInterval, +} from './state.js'; export function bodyLimitMiddleware(app) { app.use(express.json({ limit: '1mb' })); @@ -25,7 +18,7 @@ export function corsMiddleware(app) { app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', DEFAULT_ALLOWED_HEADERS.join(', ')); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-Key'); if (req.method === 'OPTIONS') return res.sendStatus(204); next(); }); @@ -40,54 +33,29 @@ export function noCacheMiddleware(app) { const requestCounts = new Map(); const RATE_LIMIT_WINDOW_MS = 60_000; -const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX || 300); -const RATE_LIMIT_READ_MAX = Number(process.env.RATE_LIMIT_READ_MAX || 3000); - -const READ_HEAVY_PATHS = [ - '/api/activity-log', - '/api/arr-queue', - '/api/bandwidth', - '/api/containers', - '/api/library/', - '/api/lookup/', - '/api/media-info/', - '/api/pending-searches', - '/api/qbittorrent/status', - '/api/setup/state', - '/api/slskd/downloads', - '/api/status', - '/api/tailscale-ip', -]; - -function getRateLimitMax(req) { - if (req.method !== 'GET') return RATE_LIMIT_MAX; - return READ_HEAVY_PATHS.some(path => req.path === path || req.path.startsWith(path)) - ? RATE_LIMIT_READ_MAX - : RATE_LIMIT_MAX; -} +const RATE_LIMIT_MAX = 300; -// The limiter is in-memory only, so prune idle buckets to keep the map bounded. function pruneExpiredRateLimitEntries() { const now = Date.now(); for (const [ip, entry] of requestCounts) { if (now > entry.resetAt) requestCounts.delete(ip); } } -setInterval(pruneExpiredRateLimitEntries, 60000); +// The limiter is in-memory only, so prune idle buckets to keep the map bounded. +registerInterval(setInterval(pruneExpiredRateLimitEntries, 60000)); export function rateLimitMiddleware(req, res, next) { - const limit = getRateLimitMax(req); const ip = req.ip || req.connection.remoteAddress || 'unknown'; - const key = `${req.method}:${ip}:${limit}`; const now = Date.now(); - const entry = requestCounts.get(key) || { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS }; - if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + RATE_LIMIT_WINDOW_MS; } + const entry = requestCounts.get(ip) || { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS }; + if (now > entry.resetAt) { + entry.count = 0; + entry.resetAt = now + RATE_LIMIT_WINDOW_MS; + } entry.count++; - requestCounts.set(key, entry); - if (entry.count > limit) { - const retryAfter = Math.ceil((entry.resetAt - now) / 1000); - res.setHeader('Retry-After', String(retryAfter)); - return res.status(429).json({ error: 'Too many requests', retryAfter, limit }); + requestCounts.set(ip, entry); + if (entry.count > RATE_LIMIT_MAX) { + return res.status(429).json({ error: 'Too many requests', retryAfter: Math.ceil((entry.resetAt - now) / 1000) }); } next(); } @@ -97,17 +65,13 @@ export function errorHandler(err, req, res, _next) { let code = 'INTERNAL_ERROR'; let message = 'Internal server error'; let details = null; - let logDetails = null; - let internalMessage = err?.message || 'Internal server error'; // Normalize known app/parser/upstream failures into one JSON error shape. if (err instanceof AppError) { status = err.status; code = err.code; - message = err.publicMessage || err.message; + message = err.message; details = err.details; - logDetails = err.logDetails; - internalMessage = err.message; } else if (err instanceof SyntaxError && err.type === 'entity.parse.failed') { status = 400; code = 'INVALID_JSON'; @@ -121,29 +85,17 @@ export function errorHandler(err, req, res, _next) { message = err.message || 'Internal server error'; } - console.error(JSON.stringify({ - scope: 'http-error', - method: req.method, - path: req.path, - status, - code, - message: internalMessage, - publicMessage: message, - details: redactLogValue(logDetails || details), - at: new Date().toISOString(), - })); + console.error(`[ERROR] ${req.method} ${req.path} \u2192 ${status} [${code}] ${message}`); const body = { error: { code, message } }; if (details) body.error.details = details; - if (CONFIG.NODE_ENV === 'development' && err.stack && !req.path.startsWith('/api/setup/')) { - body.stack = err.stack; - } + if (CONFIG.NODE_ENV === 'development' && err.stack) body.stack = err.stack; res.status(status).json(body); } export function setupGracefulShutdown(server) { - const shutdown = async (signal) => { + const shutdown = async signal => { console.log(`\n[shutdown] Received ${signal}. Cleaning up...`); clearAllIntervals(); clearFakeTorrentCheckInterval(); diff --git a/backend/src/pipeline.js b/backend/src/pipeline.js index 8d5b203..b20faa5 100644 --- a/backend/src/pipeline.js +++ b/backend/src/pipeline.js @@ -1,867 +1 @@ -import { Router } from 'express'; -import { CONFIG, TIMING } from './config.js'; -import { fetchWithTimeout, pickArrImageUrl, qbittorrentLogin } from './utils.js'; -import { - getPipeline, getPipelineItem, addPipelineItem, advancePipeline, - completePipeline, removePipelineItem, setPipelineStuck, - addLogStep, updateLogEntry, logActivity, - getActivityLog, registerInterval, -} from './state.js'; - -const router = Router(); - -const { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY, SLSKD_API_KEY, SLSKD_HOST, PROWLARR_HOST, PROWLARR_API_KEY } = CONFIG; - -// Transient watcher state lingers past pipeline updates so /pending-searches can expose terminal search outcomes. -const pendingSearches = new Map(); - -const STUCK_THRESHOLDS = { - searching: 5 * 60 * 1000, - grabbed: 2 * 60 * 1000, - downloading: 5 * 60 * 1000, - importing: 5 * 60 * 1000, -}; - -const STUCK_REASONS = { - searching_timeout: 'No releases found. Content may not be available digitally yet, or the quality profile is too restrictive.', - searching_no_results: 'No releases grabbed. Content may not have a digital release yet. Radarr/Sonarr will retry automatically when indexers find a release.', - grabbed_timeout: 'Grabbed release not appearing in download client. Check qBittorrent connection and logs.', - downloading_stalled: 'Download stalled — no active peers. This torrent may have very few seeders.', - importing_timeout: 'Import taking longer than usual. Check available disk space and file permissions.', -}; - -async function fetchRejectionSummary(releaseUrl) { - try { - const releases = await fetchWithTimeout(releaseUrl, 30000); - if (!Array.isArray(releases) || releases.length === 0) return null; - const rejected = releases.filter(r => r.rejected); - const approved = releases.filter(r => !r.rejected); - if (approved.length > 0) { - return `${approved.length} release${approved.length !== 1 ? 's' : ''} found — may be grabbing now, check downloads`; - } - const counts = {}; - for (const r of rejected) { - for (const rej of (r.rejections || [])) { - const cat = rej.includes('alias') ? 'title alias conflict' - : rej.includes('seeders') ? 'no seeders' - : rej.includes('not wanted in profile') ? 'quality profile mismatch' - : rej.includes('Unknown') ? 'unrecognized release' - : rej.includes('Wrong season') ? 'wrong season' - : rej.includes('Existing file') ? 'already at cutoff quality' - : rej.includes('Episode wasn') ? 'episode not monitored' - : 'other'; - counts[cat] = (counts[cat] || 0) + 1; - } - } - const parts = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${v} ${k}`); - return `${rejected.length} found, all rejected — ${parts.join(', ')}`; - } catch { - return null; - } -} - -function addPipelineStep(key, message) { - const item = getPipelineItem(key); - if (!item) return; - if (!item.steps) item.steps = []; - item.steps.push({ ts: Date.now(), message }); -} - -export async function watchSonarrSearch(pendingKey, commandId, seriesId, seasonNumber, logId, seriesTitle) { - const searchStart = Date.now(); - const deadline = searchStart + 4 * 60 * 1000; - - addPipelineStep(pendingKey, 'Sonarr command submitted — polling for completion…'); - - let commandCompleted = false; - let firstPoll = true; - while (Date.now() < deadline) { - if (!firstPoll) await new Promise(r => setTimeout(r, 15000)); - firstPoll = false; - try { - const cmd = await fetchWithTimeout(`${SONARR_HOST}/api/v3/command/${commandId}?apikey=${SONARR_API_KEY}`, 8000); - if (cmd.state === 'failed') { - const msg = cmd.exception || 'Search command failed'; - addLogStep(logId, `Sonarr search failed: ${msg}`, 'error'); - const entry = pendingSearches.get(pendingKey); - if (entry) Object.assign(entry, { status: 'error', error: msg }); - setPipelineStuck(pendingKey, msg); - const item = getPipelineItem(pendingKey); - if (item) item.canRetry = true; - setTimeout(() => pendingSearches.delete(pendingKey), 30000); - return; - } - if (cmd.state === 'completed') { commandCompleted = true; break; } - } catch { /* continue polling */ } - } - - if (!commandCompleted) { - const timeoutMsg = 'Sonarr search command timed out — indexers may be slow or unavailable'; - addLogStep(logId, timeoutMsg, 'warning'); - addPipelineStep(pendingKey, timeoutMsg); - const entry = pendingSearches.get(pendingKey); - if (entry) Object.assign(entry, { status: 'no_results' }); - setPipelineStuck(pendingKey, timeoutMsg); - const item = getPipelineItem(pendingKey); - if (item) item.canRetry = true; - setTimeout(() => pendingSearches.delete(pendingKey), 120000); - return; - } - - addPipelineStep(pendingKey, 'Sonarr finished querying indexers — waiting for grab history…'); - let grabs = []; - const historyDeadline = Date.now() + 30000; - // Sonarr can finish the search command before the matching grab shows up in history. - while (Date.now() < historyDeadline && grabs.length === 0) { - await new Promise(r => setTimeout(r, 3000)); - try { - const histCheck = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/history?pageSize=50&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, - 8000 - ); - grabs = (histCheck.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.seriesId !== seriesId) return false; - if (seasonNumber != null && h.episode?.seasonNumber !== seasonNumber) return false; - return true; - }); - } catch { /* continue polling */ } - } - - if (grabs.length === 0) { - try { - const history = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/history?pageSize=50&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, - 8000 - ); - grabs = (history.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.seriesId !== seriesId) return false; - if (seasonNumber != null && h.episode?.seasonNumber !== seasonNumber) return false; - return true; - }); - } catch { /* ignore */ } - } - - try { - const entry = pendingSearches.get(pendingKey); - if (grabs.length > 0) { - const titles = grabs.map(g => g.sourceTitle || 'release').slice(0, 2).join(', '); - addLogStep(logId, `Grabbed ${grabs.length} episode(s): ${titles}`, 'success'); - addPipelineStep(pendingKey, `Grabbed ${grabs.length} release(s) — sending to qBittorrent`); - if (entry) Object.assign(entry, { status: 'grabbed' }); - advancePipeline(pendingKey, 'grabbed'); - setTimeout(() => pendingSearches.delete(pendingKey), 90000); - } else { - const releaseUrl = seasonNumber != null - ? `${SONARR_HOST}/api/v3/release?seriesId=${seriesId}&seasonNumber=${seasonNumber}&apikey=${SONARR_API_KEY}` - : `${SONARR_HOST}/api/v3/release?seriesId=${seriesId}&apikey=${SONARR_API_KEY}`; - const rejSummary = await fetchRejectionSummary(releaseUrl); - const noResultsMsg = rejSummary ? `No grab — ${rejSummary}` : 'No matching releases found — check indexers or episode availability'; - addLogStep(logId, noResultsMsg, rejSummary ? 'warning' : 'warning'); - addPipelineStep(pendingKey, noResultsMsg); - if (entry) Object.assign(entry, { status: 'no_results' }); - setPipelineStuck(pendingKey, rejSummary || STUCK_REASONS.searching_no_results); - const item = getPipelineItem(pendingKey); - if (item) item.canRetry = true; - setTimeout(() => pendingSearches.delete(pendingKey), 120000); - } - } catch (err) { - console.error('watchSonarrSearch history check failed:', err.message); - setTimeout(() => pendingSearches.delete(pendingKey), 60000); - } -} - -export async function watchRadarrSearch(pipelineKey, commandId, movieId, logId, movieTitle) { - const searchStart = Date.now(); - const deadline = searchStart + 4 * 60 * 1000; - - addPipelineStep(pipelineKey, 'Radarr command submitted — polling for completion…'); - - let commandCompleted = false; - let firstPoll = true; - while (Date.now() < deadline) { - if (!firstPoll) await new Promise(r => setTimeout(r, 15000)); - firstPoll = false; - try { - const cmd = await fetchWithTimeout(`${RADARR_HOST}/api/v3/command/${commandId}?apikey=${RADARR_API_KEY}`, 8000); - if (cmd.state === 'failed') { - const msg = cmd.exception || 'Radarr search command failed'; - setPipelineStuck(pipelineKey, msg); - const item = getPipelineItem(pipelineKey); - if (item) item.canRetry = true; - if (logId) addLogStep(logId, `Radarr search failed: ${msg}`, 'error'); - return; - } - if (cmd.state === 'completed') { commandCompleted = true; break; } - } catch { /* continue polling */ } - } - - if (!commandCompleted) { - const timeoutMsg = 'Radarr search command timed out — indexers may be slow or unavailable'; - if (logId) addLogStep(logId, timeoutMsg, 'warning'); - addPipelineStep(pipelineKey, timeoutMsg); - setPipelineStuck(pipelineKey, timeoutMsg); - const item = getPipelineItem(pipelineKey); - if (item) item.canRetry = true; - return; - } - - addPipelineStep(pipelineKey, 'Radarr finished querying indexers — waiting for grab history…'); - let grabs = []; - const historyDeadline = Date.now() + 30000; - // Radarr can finish the search command before the matching grab shows up in history. - while (Date.now() < historyDeadline && grabs.length === 0) { - await new Promise(r => setTimeout(r, 3000)); - try { - const histCheck = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/history?pageSize=50&includeMovie=true&apikey=${RADARR_API_KEY}`, - 8000 - ); - grabs = (histCheck.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.movieId !== movieId) return false; - return true; - }); - } catch { /* continue polling */ } - } - - if (grabs.length === 0) { - try { - const history = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/history?pageSize=50&includeMovie=true&apikey=${RADARR_API_KEY}`, - 8000 - ); - grabs = (history.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.movieId !== movieId) return false; - return true; - }); - } catch { /* ignore */ } - } - - try { - if (grabs.length > 0) { - const sourceTitle = grabs[0].sourceTitle || 'release'; - addPipelineStep(pipelineKey, `Grabbed: ${sourceTitle} — sending to qBittorrent`); - advancePipeline(pipelineKey, 'grabbed'); - if (logId) addLogStep(logId, `Grabbed: ${sourceTitle}`, 'success'); - } else { - const releaseUrl = `${RADARR_HOST}/api/v3/release?movieId=${movieId}&apikey=${RADARR_API_KEY}`; - const rejSummary = await fetchRejectionSummary(releaseUrl); - const noResultsMsg = rejSummary ? `No grab — ${rejSummary}` : 'No matching releases found — check indexers or availability'; - addPipelineStep(pipelineKey, noResultsMsg); - setPipelineStuck(pipelineKey, rejSummary || STUCK_REASONS.searching_no_results); - const item = getPipelineItem(pipelineKey); - if (item) item.canRetry = true; - if (logId) addLogStep(logId, noResultsMsg, 'warning'); - } - } catch (err) { - console.error('watchRadarrSearch history check failed:', err.message); - } -} - -export async function arrGrab({ service, host, apiKey, guid, indexerId, downloadUrl, title }) { - const endpoint = downloadUrl ? 'release/push' : 'release'; - const body = downloadUrl - ? { title, downloadUrl, protocol: 'torrent', ...(indexerId ? { indexerId } : {}) } - : { guid, indexerId }; - console.log(`[grab] ${service} ${endpoint}`, { - hasGuid: Boolean(guid), - hasDownloadUrl: Boolean(downloadUrl), - indexerId: indexerId ?? null, - }); - const resp = await fetch(`${host}/api/v3/${endpoint}?apikey=${apiKey}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - const errBody = await resp.text().catch(() => ''); - console.error(`[grab] ${service} ${endpoint} ${resp.status}:`, errBody); - let msg; - try { const j = JSON.parse(errBody); msg = (Array.isArray(j) ? j[0]?.errorMessage : j?.message) || errBody; } catch { msg = errBody; } - throw new Error(msg || `${service} grab failed: HTTP ${resp.status}`); - } - return resp.json(); -} - -function checkPipelineStuck() { - try { - const now = Date.now(); - const items = getPipeline(); - for (const item of items) { - if (item.stage === 'complete' || item.stage === 'failed') continue; - if (item.stage === 'stuck' && item.stuckAt && (now - item.stuckAt > 30 * 60 * 1000)) { - removePipelineItem(item.key); - continue; - } - if (item.stage === 'stuck') continue; - const threshold = STUCK_THRESHOLDS[item.stage]; - if (!threshold) continue; - const stageStartedAt = item.stageStartedAt || item.stageChangedAt || item.startedAt || item.createdAt || now; - if (now - stageStartedAt > threshold) { - const reason = STUCK_REASONS[`${item.stage}_timeout`] || `Taking longer than expected at stage: ${item.stage}`; - setPipelineStuck(item.key, reason); - // Leave stuck cards visible for manual retry/debug before aging them out separately. - const i = getPipelineItem(item.key); - if (i) { i.canRetry = item.stage === 'searching' || item.stage === 'grabbed'; i.stuckAt = Date.now(); } - if (item.logId) addLogStep(item.logId, `Stuck at "${item.stage}": ${reason}`, 'warning'); - } - } - } catch (e) { - console.error('[checkPipelineStuck] error:', e.message); - } -} - -registerInterval(setInterval(checkPipelineStuck, TIMING.CHECK_STUCK_INTERVAL_MS || 30000)); - -const queueAlerts = new Map(); - -export function restoreQueueAlertsFromActivityLog() { - queueAlerts.clear(); - for (const entry of getActivityLog()) { - if (entry?.type !== 'queue' || entry?.status !== 'error') continue; - const service = entry.details?.service || entry.context?.service; - const discriminator = entry.details?.downloadId || entry.details?.queueId || null; - if (!service || !discriminator) continue; - const key = `${service}-q-${discriminator}`; - if (!queueAlerts.has(key)) { - queueAlerts.set(key, { logId: entry.id, status: 'error' }); - } - } -} - -export async function monitorQueues() { - const tasks = []; - - if (LIDARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/queue?page=1&pageSize=100&includeArtist=true&includeAlbum=true&apikey=${LIDARR_API_KEY}`, 8000); - const lidarrCounts = new Map(); - for (const r of (data.records || [])) { - const dlid = r.downloadId || `__id_${r.id}`; - lidarrCounts.set(dlid, (lidarrCounts.get(dlid) || 0) + 1); - } - const seenLidarr = new Set(); - for (const item of (data.records || [])) { - const dlid = item.downloadId || `__id_${item.id}`; - if (seenLidarr.has(dlid)) continue; - seenLidarr.add(dlid); - const key = `lidarr-q-${dlid}`; - const artist = item.artist?.artistName || 'Unknown'; - const album = item.album?.title || 'Unknown'; - const trackCount = lidarrCounts.get(dlid) || 1; - const label = trackCount > 1 ? `${artist} - ${album} (${trackCount} tracks)` : `${artist} - ${album}`; - const hasError = item.status === 'warning' || item.status === 'error' || item.trackedDownloadStatus === 'warning' || item.trackedDownloadStatus === 'error' || (item.statusMessages?.length > 0); - const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); - const errorDetail = item.errorMessage || msgs.join('; ') || ''; - - if (hasError && !queueAlerts.has(key)) { - const errorDetails = { - status: item.status, - trackedDownloadStatus: item.trackedDownloadStatus, - trackedDownloadState: item.trackedDownloadState, - protocol: item.protocol, - errorMessage: item.errorMessage || null, - statusMessages: (item.statusMessages || []).map(m => ({ - title: m.title, - messages: m.messages || [], - })), - }; - const logId = logActivity('queue', `${label}: ${errorDetail || 'download issue'}`, errorDetails, 'error', { service: 'lidarr', artistName: artist, title: album }); - queueAlerts.set(key, { logId, status: 'error' }); - } else if (!hasError && queueAlerts.has(key)) { - const alert = queueAlerts.get(key); - updateLogEntry(alert.logId, { status: 'success', message: `${label}: resolved` }); - queueAlerts.delete(key); - } - - if (item.status === 'downloading' && item.trackedDownloadStatus === 'ok') { - const pKey = `lidarr-dl-${dlid}`; - if (!queueAlerts.has(pKey)) { - const protocol = item.protocol === 'torrent' ? 'torrent' : 'Soulseek'; - const logId = logActivity('download', `Downloading ${label} via ${protocol}`, { service: 'lidarr', queueId: item.id, downloadId: item.downloadId, protocol: item.protocol }, 'pending', { service: 'lidarr', artistName: artist, title: album }); - queueAlerts.set(pKey, { logId, status: 'pending' }); - } - } - } - - const activeLidarrDlids = new Set((data.records || []).map(r => r.downloadId || `__id_${r.id}`)); - for (const [key, alert] of queueAlerts) { - if (key.startsWith('lidarr-dl-') && alert.status === 'pending') { - const trackedDlid = key.replace('lidarr-dl-', ''); - const stillInQueue = activeLidarrDlids.has(trackedDlid); - if (!stillInQueue) { - const logEntry = getActivityLog().find(e => e.id === alert.logId); - updateLogEntry(alert.logId, { status: 'success', message: logEntry?.message?.replace('Downloading', 'Downloaded') || 'Download completed' }); - queueAlerts.delete(key); - } - } - } - } catch (err) { console.error('Lidarr queue monitor:', err.message); } - })()); - } - - if (SONARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${SONARR_HOST}/api/v3/queue?page=1&pageSize=100&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, 8000); - const sonarrCounts = new Map(); - for (const r of (data.records || [])) { - const dlid = r.downloadId || `__id_${r.id}`; - sonarrCounts.set(dlid, (sonarrCounts.get(dlid) || 0) + 1); - } - const seenSonarr = new Set(); - for (const item of (data.records || [])) { - const dlid = item.downloadId || `__id_${item.id}`; - const isFirstForDlid = !seenSonarr.has(dlid); - if (isFirstForDlid) seenSonarr.add(dlid); - const key = `sonarr-q-${dlid}`; - const series = item.series?.title || 'Unknown'; - const ep = item.episode ? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}` : ''; - const epCount = sonarrCounts.get(dlid) || 1; - const label = epCount > 1 ? `${series} (${epCount} episodes)` : `${series} ${ep}`.trim(); - const hasError = item.status === 'warning' || item.status === 'error' || item.trackedDownloadStatus === 'warning' || item.trackedDownloadStatus === 'error' || (item.statusMessages?.length > 0); - const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); - const errorDetail = item.errorMessage || msgs.join('; ') || ''; - const queueSeriesId = item.seriesId || item.series?.id; - const pipelineItems = getPipeline(); - const relatedPipelineItems = pipelineItems.filter( - pItem => pItem.service === 'sonarr' && pItem.seriesId && pItem.seriesId === queueSeriesId, - ); - const shouldTrackQueueAlert = relatedPipelineItems.length > 0 || queueAlerts.has(key); - - if (isFirstForDlid && hasError && !queueAlerts.has(key) && shouldTrackQueueAlert) { - const logId = logActivity('queue', `${label}: ${errorDetail || 'download issue'}`, { service: 'sonarr', queueId: item.id, downloadId: item.downloadId, recordCount: epCount }, 'error', { service: 'sonarr', title: series }); - queueAlerts.set(key, { logId, status: 'error' }); - } else if (isFirstForDlid && !hasError && queueAlerts.has(key)) { - const alert = queueAlerts.get(key); - updateLogEntry(alert.logId, { status: 'success', message: `${label}: resolved` }); - queueAlerts.delete(key); - } - - for (const pItem of relatedPipelineItems) { - if (pItem.stage === 'grabbed' || pItem.stage === 'searching' || pItem.stage === 'stuck') { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : 0; - advancePipeline(pItem.key, 'downloading', { queueId: item.id, progress }); - } else if (pItem.stage === 'downloading' && pItem.queueId === item.id) { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : pItem.progress; - const updated = getPipelineItem(pItem.key); - if (updated) updated.progress = progress; - if (hasError) { - const statusMsgs = (item.statusMessages || []).flatMap(m => m.messages || [m.title]).filter(Boolean); - setPipelineStuck(pItem.key, statusMsgs.join(' ') || 'Download issue — check Sonarr for details'); - if (pItem.logId) addLogStep(pItem.logId, `Queue warning: ${statusMsgs.join(' ')}`, 'warning'); - } - } - } - } - - const pipelineItems = getPipeline(); - for (const pItem of pipelineItems) { - if (pItem.service !== 'sonarr' || pItem.stage !== 'downloading' || !pItem.queueId) continue; - const stillInQueue = (data.records || []).some(r => r.id === pItem.queueId); - if (!stillInQueue) { - // Queue disappearance is the earliest reliable handoff from downloading to import. - advancePipeline(pItem.key, 'importing'); - if (pItem.logId) addLogStep(pItem.logId, 'Download complete — importing to library', 'success'); - setTimeout(() => completePipeline(pItem.key), 3 * 60 * 1000); - } - } - } catch (err) { console.error('Sonarr queue monitor:', err.message); } - })()); - } - - if (RADARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${RADARR_HOST}/api/v3/queue?page=1&pageSize=100&includeMovie=true&apikey=${RADARR_API_KEY}`, 8000); - const seenRadarr = new Set(); - for (const item of (data.records || [])) { - const dlid = item.downloadId || `__id_${item.id}`; - const isFirstForDlid = !seenRadarr.has(dlid); - if (isFirstForDlid) seenRadarr.add(dlid); - const key = `radarr-q-${dlid}`; - const title = item.movie?.title || 'Unknown'; - const hasError = item.status === 'warning' || item.status === 'error' || item.trackedDownloadStatus === 'warning' || item.trackedDownloadStatus === 'error' || (item.statusMessages?.length > 0); - const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); - const errorDetail = item.errorMessage || msgs.join('; ') || ''; - const queueMovieId = item.movieId || item.movie?.id; - const pipelineItems = getPipeline(); - const relatedPipelineItems = pipelineItems.filter( - pItem => pItem.service === 'radarr' && pItem.movieId && pItem.movieId === queueMovieId, - ); - const shouldTrackQueueAlert = relatedPipelineItems.length > 0 || queueAlerts.has(key); - - if (isFirstForDlid && hasError && !queueAlerts.has(key) && shouldTrackQueueAlert) { - const logId = logActivity('queue', `${title}: ${errorDetail || 'download issue'}`, { service: 'radarr', queueId: item.id, downloadId: item.downloadId }, 'error', { service: 'radarr', title }); - queueAlerts.set(key, { logId, status: 'error' }); - } else if (isFirstForDlid && !hasError && queueAlerts.has(key)) { - const alert = queueAlerts.get(key); - updateLogEntry(alert.logId, { status: 'success', message: `${title}: resolved` }); - queueAlerts.delete(key); - } - - for (const pItem of relatedPipelineItems) { - if (pItem.stage === 'grabbed' || pItem.stage === 'searching' || pItem.stage === 'stuck') { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : 0; - advancePipeline(pItem.key, 'downloading', { queueId: item.id, progress }); - if (pItem.logId) addLogStep(pItem.logId, 'Release grabbed — now downloading', 'success'); - } else if (pItem.stage === 'downloading' && pItem.queueId === item.id) { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : pItem.progress; - const updated = getPipelineItem(pItem.key); - if (updated) updated.progress = progress; - if (hasError) { - const statusMsgs = (item.statusMessages || []).flatMap(m => m.messages || [m.title]).filter(Boolean); - setPipelineStuck(pItem.key, statusMsgs.join(' ') || 'Download issue — check Radarr for details'); - if (pItem.logId) addLogStep(pItem.logId, `Queue warning: ${statusMsgs.join(' ')}`, 'warning'); - } - } - } - } - - const pipelineItems = getPipeline(); - for (const pItem of pipelineItems) { - if (pItem.service !== 'radarr' || pItem.stage !== 'downloading' || !pItem.queueId) continue; - const stillInQueue = (data.records || []).some(r => r.id === pItem.queueId); - if (!stillInQueue) { - advancePipeline(pItem.key, 'importing'); - if (pItem.logId) addLogStep(pItem.logId, 'Download complete — importing to library', 'success'); - setTimeout(() => completePipeline(pItem.key), 3 * 60 * 1000); - } - } - } catch (err) { console.error('Radarr queue monitor:', err.message); } - })()); - } - - await Promise.allSettled(tasks); -} - -registerInterval(setInterval(monitorQueues, TIMING.MONITOR_QUEUES_INTERVAL_MS || 15000)); - -// ─── Routes ───────────────────────────────────────────────────────────────── - -router.get('/pipeline', (req, res) => { - const items = getPipeline(); - res.json(items.map(item => ({ - key: item.key, - service: item.service, - title: item.title, - subtitle: item.subtitle, - posterUrl: item.posterUrl, - stage: item.stage, - stageStartedAt: item.stageStartedAt || item.stageChangedAt || item.startedAt || item.createdAt, - startedAt: item.startedAt || item.createdAt, - stuckReason: item.stuckReason || item.error, - stuckAt: item.stuckAt, - canRetry: item.canRetry || false, - progress: item.progress, - speed: item.speed, - eta: item.eta, - logId: item.logId, - steps: item.steps || [], - seriesId: item.seriesId || null, - movieId: item.movieId || null, - artistId: item.artistId || null, - seasonNumbers: item.seasonNumbers || null, - queueId: item.queueId || null, - retryId: item.retryId || null, - }))); -}); - -router.delete('/pipeline/:key', (req, res) => { - removePipelineItem(req.params.key); - res.json({ success: true }); -}); - -router.post('/pipeline/:key/retry', async (req, res) => { - const item = getPipelineItem(req.params.key); - if (!item) return res.status(404).json({ error: 'Pipeline item not found' }); - try { - if (item.service === 'sonarr' && item.retryId && SONARR_API_KEY) { - const cmdBody = item.retrySeasonNumbers?.length - ? { name: 'SeasonSearch', seriesId: item.retryId, seasonNumber: item.retrySeasonNumbers[0] } - : { name: 'SeriesSearch', seriesId: item.retryId }; - const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cmdBody), - }); - if (!cmdResp.ok) throw new Error(`Sonarr command failed: HTTP ${cmdResp.status}`); - const cmdData = await cmdResp.json(); - advancePipeline(item.key, 'searching'); - if (item.logId) addLogStep(item.logId, 'Retrying search…', 'info'); - watchSonarrSearch(item.key, cmdData.id, item.retryId, item.retrySeasonNumbers?.[0] ?? null, item.logId, item.title) - .catch(e => console.error('watchSonarrSearch retry:', e.message)); - } else if (item.service === 'radarr' && item.retryId && RADARR_API_KEY) { - const cmdResp = await fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'MoviesSearch', movieIds: [item.retryId] }), - }); - if (!cmdResp.ok) throw new Error(`Radarr command failed: HTTP ${cmdResp.status}`); - const cmdData = await cmdResp.json(); - advancePipeline(item.key, 'searching'); - if (item.logId) addLogStep(item.logId, 'Retrying search…', 'info'); - watchRadarrSearch(item.key, cmdData.id, item.retryId, item.logId, item.title) - .catch(e => console.error('watchRadarrSearch retry:', e.message)); - } else { - return res.status(400).json({ error: 'Cannot retry this item type' }); - } - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -router.delete('/pipeline/:key/cancel', async (req, res) => { - const item = getPipelineItem(req.params.key); - if (!item) return res.status(404).json({ error: 'Not found' }); - try { - if (item.torrentHash) { - try { - const { qbHost, cookie } = await qbittorrentLogin(); - await fetch(`${qbHost}/api/v2/torrents/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie }, - body: `hashes=${item.torrentHash}&deleteFiles=true`, - }); - } catch {} - } - if (item.queueId) { - try { - if (item.service === 'sonarr' && SONARR_API_KEY) { - await fetch(`${SONARR_HOST}/api/v3/queue/${item.queueId}?removeFromClient=true&blocklist=false&apikey=${SONARR_API_KEY}`, { method: 'DELETE' }); - } else if (item.service === 'radarr' && RADARR_API_KEY) { - await fetch(`${RADARR_HOST}/api/v3/queue/${item.queueId}?removeFromClient=true&blocklist=false&apikey=${RADARR_API_KEY}`, { method: 'DELETE' }); - } - } catch {} - } - removePipelineItem(req.params.key); - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -router.post('/pipeline/:key/monitor', (req, res) => { - const item = getPipelineItem(req.params.key); - if (!item) return res.status(404).json({ error: 'Not found' }); - if (item.logId) addLogStep(item.logId, 'Set to monitor — will grab automatically when released', 'info'); - removePipelineItem(req.params.key); - res.json({ success: true }); -}); - -router.post('/command/search', async (req, res) => { - const { service, id, seasonNumbers, albumIds } = req.body; - if (!service) return res.status(400).json({ error: 'Missing service' }); - if (!id && service !== 'lidarr') return res.status(400).json({ error: 'Missing id' }); - - try { - if (service === 'sonarr' && SONARR_API_KEY) { - const seriesResp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`); - if (!seriesResp.ok) throw new Error(`Failed to fetch series from Sonarr: HTTP ${seriesResp.status}`); - const seriesData = await seriesResp.json(); - const seriesTitle = seriesData.title || 'Unknown'; - const seriesPosterUrl = pickArrImageUrl(seriesData.images || [], 'poster', 'sonarr'); - - let monitoringChanged = false; - if (!seriesData.monitored) { seriesData.monitored = true; monitoringChanged = true; } - for (const season of (seriesData.seasons || [])) { - if (season.seasonNumber === 0) continue; - const targeted = seasonNumbers?.length ? seasonNumbers.includes(season.seasonNumber) : true; - if (targeted && !season.monitored) { season.monitored = true; monitoringChanged = true; } - } - if (monitoringChanged) { - const putResp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(seriesData), - }); - if (!putResp.ok) console.error(`Failed to enable monitoring for series ${id}: HTTP ${putResp.status}`); - } - - if (seasonNumbers?.length) { - for (const sn of seasonNumbers) { - const snLabel = seasonNumbers.length > 1 ? `${seriesTitle} S${sn}` : `${seriesTitle} Season ${sn}`; - const logId = logActivity('download', `Searching: ${snLabel}`, { seriesId: id, season: sn }, 'pending', { service: 'sonarr', seriesId: id, season: sn, title: seriesTitle }); - if (monitoringChanged) addLogStep(logId, `Auto-enabled monitoring for ${snLabel}`, 'info'); - const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'SeasonSearch', seriesId: id, seasonNumber: sn }), - }); - if (!cmdResp.ok) throw new Error(`Sonarr command failed: HTTP ${cmdResp.status}`); - const cmdData = await cmdResp.json(); - updateLogEntry(logId, { status: 'info', message: `Sonarr searching: ${snLabel}` }); - const pendingKey = `sonarr-${id}-${sn}-${Date.now()}`; - pendingSearches.set(pendingKey, { key: pendingKey, service: 'sonarr', title: seriesTitle, subtitle: `Season ${sn}`, seasons: [sn], logId, seriesId: id, posterUrl: seriesPosterUrl, startedAt: Date.now(), status: 'searching' }); - addPipelineItem(pendingKey, { service: 'sonarr', title: seriesTitle, subtitle: `Season ${sn}`, posterUrl: seriesPosterUrl, logId, seriesId: id, seasonNumbers: [sn], retryId: id }); - advancePipeline(pendingKey, 'searching'); - watchSonarrSearch(pendingKey, cmdData.id, id, sn, logId, seriesTitle).catch(e => console.error('watchSonarrSearch:', e.message)); - } - } else { - const logId = logActivity('download', `Searching: ${seriesTitle}`, { seriesId: id }, 'pending', { service: 'sonarr', seriesId: id, title: seriesTitle }); - if (monitoringChanged) addLogStep(logId, `Auto-enabled monitoring for ${seriesTitle}`, 'info'); - const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }), - }); - if (!cmdResp.ok) throw new Error(`Sonarr command failed: HTTP ${cmdResp.status}`); - const cmdData = await cmdResp.json(); - updateLogEntry(logId, { status: 'info', message: `Sonarr searching: ${seriesTitle}` }); - const pendingKey = `sonarr-${id}-all-${Date.now()}`; - pendingSearches.set(pendingKey, { key: pendingKey, service: 'sonarr', title: seriesTitle, subtitle: 'All seasons', seasons: null, logId, seriesId: id, posterUrl: seriesPosterUrl, startedAt: Date.now(), status: 'searching' }); - addPipelineItem(pendingKey, { service: 'sonarr', title: seriesTitle, subtitle: 'All seasons', posterUrl: seriesPosterUrl, logId, seriesId: id, seasonNumbers: null, retryId: id }); - advancePipeline(pendingKey, 'searching'); - watchSonarrSearch(pendingKey, cmdData.id, id, null, logId, seriesTitle).catch(e => console.error('watchSonarrSearch:', e.message)); - } - res.json({ success: true }); - - } else if (service === 'radarr' && RADARR_API_KEY) { - let movieTitle = 'Unknown'; - let moviePosterUrl = null; - try { - const movieResp = await fetch(`${RADARR_HOST}/api/v3/movie/${id}?apikey=${RADARR_API_KEY}`); - if (movieResp.ok) { - const movieData = await movieResp.json(); - movieTitle = movieData.title || 'Unknown'; - moviePosterUrl = pickArrImageUrl(movieData.images || [], 'poster', 'radarr'); - } - } catch {} - const logId = logActivity('download', `Searching for "${movieTitle}"`, { movieId: id }, 'pending', { service: 'radarr', title: movieTitle, movieId: id }); - const pipelineKey = `radarr-${id}-${Date.now()}`; - addPipelineItem(pipelineKey, { service: 'radarr', title: movieTitle, subtitle: 'Movie', posterUrl: moviePosterUrl, logId, movieId: id, retryId: id }); - advancePipeline(pipelineKey, 'searching'); - const resp = await fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'MoviesSearch', movieIds: [id] }), - }); - if (!resp.ok) throw new Error(`Radarr command failed: HTTP ${resp.status}`); - const cmdData = await resp.json(); - updateLogEntry(logId, { status: 'info', message: `Radarr searching for "${movieTitle}"` }); - pendingSearches.set(pipelineKey, { key: pipelineKey, title: movieTitle, service: 'radarr', type: 'movie', startedAt: Date.now(), status: 'searching' }); - watchRadarrSearch(pipelineKey, cmdData.id, id, logId, movieTitle).catch(e => console.error('watchRadarrSearch:', e.message)); - res.json({ success: true }); - - } else if (service === 'lidarr' && LIDARR_API_KEY) { - if (albumIds?.length) { - let albumNames = []; - try { - const albumDetails = await Promise.all(albumIds.map(aid => fetchWithTimeout(`${LIDARR_HOST}/api/v1/album/${aid}?apikey=${LIDARR_API_KEY}`))); - albumNames = albumDetails.map(a => a.title || 'Unknown'); - } catch {} - const logId = logActivity('download', `Searching Lidarr for ${albumIds.length} album(s): ${albumNames.join(', ') || albumIds.join(', ')}`, { albumIds, albumNames }, 'pending', { service: 'lidarr', albumNames, albumIds }); - const resp = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'AlbumSearch', albumIds }), - }); - if (!resp.ok) throw new Error(`Lidarr command failed: HTTP ${resp.status}`); - updateLogEntry(logId, { status: 'success', message: `Lidarr searching for ${albumIds.length} album(s): ${albumNames.join(', ') || 'unknown'}` }); - } else { - const logId = logActivity('download', 'Searching Lidarr for artist', { artistId: id }, 'pending'); - const resp = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'ArtistSearch', artistId: id }), - }); - if (!resp.ok) throw new Error(`Lidarr command failed: HTTP ${resp.status}`); - updateLogEntry(logId, { status: 'success', message: 'Lidarr artist search triggered' }); - } - res.json({ success: true }); - - } else { - return res.status(400).json({ error: 'Unknown service or not configured' }); - } - } catch (err) { - logActivity('error', `Search command failed: ${err.message}`, { service, id }, 'error'); - console.error('Command/search error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -router.post('/grab', async (req, res) => { - const { service, guid, indexerId, pipelineKey, downloadUrl, title } = req.body; - if (!guid && !downloadUrl) return res.status(400).json({ error: 'Missing guid or downloadUrl' }); - if (downloadUrl && !title) return res.status(400).json({ error: 'Missing title for downloadUrl grab' }); - const cfg = service === 'radarr' && RADARR_API_KEY - ? { service: 'Radarr', host: RADARR_HOST, apiKey: RADARR_API_KEY } - : service === 'sonarr' && SONARR_API_KEY - ? { service: 'Sonarr', host: SONARR_HOST, apiKey: SONARR_API_KEY } - : null; - if (!cfg) return res.status(400).json({ error: 'Unknown service' }); - try { - await arrGrab({ ...cfg, guid, indexerId, downloadUrl, title }); - if (pipelineKey) advancePipeline(pipelineKey, 'grabbed'); - res.json({ success: true }); - } catch (err) { - console.error('Grab error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -router.get('/arr-queue', async (req, res) => { - const results = []; - const tasks = []; - if (SONARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${SONARR_HOST}/api/v3/queue?page=1&pageSize=100&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, 8000); - for (const item of (data.records || [])) { - const ep = item.episode ? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}` : null; - const msgs = (item.statusMessages || []).map(m => m.title || (m.messages || []).join(', ')).filter(Boolean); - results.push({ - id: `sonarr-${item.id}`, service: 'sonarr', - seriesId: item.seriesId || item.series?.id || null, - title: item.series?.title || 'Unknown', episode: ep, - seasonNumber: item.episode?.seasonNumber, - status: item.status, trackedStatus: item.trackedDownloadStatus, - progress: item.size > 0 ? Math.round((1 - item.sizeleft / item.size) * 100) : 0, - size: item.size, sizeleft: item.sizeleft, - errorMessage: item.errorMessage || msgs.join('; ') || null, - addedAt: item.added, - posterUrl: pickArrImageUrl(item.series?.images || [], 'poster', 'sonarr'), - }); - } - } catch (err) { console.error('arr-queue sonarr:', err.message); } - })()); - } - if (RADARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${RADARR_HOST}/api/v3/queue?page=1&pageSize=100&includeMovie=true&apikey=${RADARR_API_KEY}`, 8000); - for (const item of (data.records || [])) { - const msgs = (item.statusMessages || []).map(m => m.title || (m.messages || []).join(', ')).filter(Boolean); - results.push({ - id: `radarr-${item.id}`, service: 'radarr', - movieId: item.movieId || item.movie?.id || null, - title: item.movie?.title || 'Unknown', episode: null, seasonNumber: null, - status: item.status, trackedStatus: item.trackedDownloadStatus, - progress: item.size > 0 ? Math.round((1 - item.sizeleft / item.size) * 100) : 0, - size: item.size, sizeleft: item.sizeleft, - errorMessage: item.errorMessage || msgs.join('; ') || null, - addedAt: item.added, - posterUrl: pickArrImageUrl(item.movie?.images || [], 'poster', 'radarr'), - }); - } - } catch (err) { console.error('arr-queue radarr:', err.message); } - })()); - } - await Promise.allSettled(tasks); - res.json(results); -}); - -router.get('/pending-searches', (req, res) => { - res.json([...pendingSearches.values()].map(s => ({ - key: s.key, service: s.service, title: s.title, subtitle: s.subtitle, - seasons: s.seasons, startedAt: s.startedAt, status: s.status, - posterUrl: s.posterUrl, error: s.error || null, - }))); -}); - -export default router; +export { arrGrab, monitorQueues, watchRadarrSearch, watchSonarrSearch, default } from './pipeline/index.js'; diff --git a/backend/src/pipeline/bootstrap.js b/backend/src/pipeline/bootstrap.js new file mode 100644 index 0000000..5f43a70 --- /dev/null +++ b/backend/src/pipeline/bootstrap.js @@ -0,0 +1,7 @@ +import { TIMING } from '../config.js'; +import { registerInterval } from '../state.js'; +import { checkPipelineStuck } from './stuckCheck.js'; +import { monitorQueues } from './queueMonitor.js'; + +registerInterval(setInterval(checkPipelineStuck, TIMING.CHECK_STUCK_INTERVAL_MS || 30000)); +registerInterval(setInterval(monitorQueues, TIMING.MONITOR_QUEUES_INTERVAL_MS || 15000)); diff --git a/backend/src/pipeline/constants.js b/backend/src/pipeline/constants.js new file mode 100644 index 0000000..ae86916 --- /dev/null +++ b/backend/src/pipeline/constants.js @@ -0,0 +1,16 @@ +export const STUCK_THRESHOLDS = { + searching: 5 * 60 * 1000, + grabbed: 2 * 60 * 1000, + downloading: 5 * 60 * 1000, + importing: 5 * 60 * 1000, +}; + +export const STUCK_REASONS = { + searching_timeout: + 'No releases found. Content may not be available digitally yet, or the quality profile is too restrictive.', + searching_no_results: + 'No releases grabbed. Content may not have a digital release yet. Radarr/Sonarr will retry automatically when indexers find a release.', + grabbed_timeout: 'Grabbed release not appearing in download client. Check qBittorrent connection and logs.', + downloading_stalled: 'Download stalled — no active peers. This torrent may have very few seeders.', + importing_timeout: 'Import taking longer than usual. Check available disk space and file permissions.', +}; diff --git a/backend/src/pipeline/grab.js b/backend/src/pipeline/grab.js new file mode 100644 index 0000000..1638edd --- /dev/null +++ b/backend/src/pipeline/grab.js @@ -0,0 +1,29 @@ +export async function arrGrab({ service, host, apiKey, guid, indexerId, downloadUrl, title }) { + const endpoint = downloadUrl ? 'release/push' : 'release'; + const body = downloadUrl + ? { title, downloadUrl, protocol: 'torrent', ...(indexerId ? { indexerId } : {}) } + : { guid, indexerId }; + console.log(`[grab] ${service} ${endpoint}`, { + hasGuid: Boolean(guid), + hasDownloadUrl: Boolean(downloadUrl), + indexerId: indexerId ?? null, + }); + const resp = await fetch(`${host}/api/v3/${endpoint}?apikey=${apiKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const errBody = await resp.text().catch(() => ''); + console.error(`[grab] ${service} ${endpoint} ${resp.status}:`, errBody); + let msg; + try { + const j = JSON.parse(errBody); + msg = (Array.isArray(j) ? j[0]?.errorMessage : j?.message) || errBody; + } catch { + msg = errBody; + } + throw new Error(msg || `${service} grab failed: HTTP ${resp.status}`); + } + return resp.json(); +} diff --git a/backend/src/pipeline/handlers.js b/backend/src/pipeline/handlers.js new file mode 100644 index 0000000..187df6e --- /dev/null +++ b/backend/src/pipeline/handlers.js @@ -0,0 +1,487 @@ +import { Router } from 'express'; +import { CONFIG } from '../config.js'; +import { fetchWithTimeout, pickArrImageUrl, qbittorrentLogin } from '../utils.js'; +import { + getPipeline, + getPipelineItem, + addPipelineItem, + advancePipeline, + removePipelineItem, + addLogStep, + updateLogEntry, + logActivity, +} from '../state.js'; +import { arrGrab } from './grab.js'; +import { pendingSearches } from './pendingSearches.js'; +import { watchRadarrSearch, watchSonarrSearch } from './searchWatchers.js'; + +const router = Router(); + +const { + RADARR_HOST, + RADARR_API_KEY, + SONARR_HOST, + SONARR_API_KEY, + LIDARR_HOST, + LIDARR_API_KEY, +} = CONFIG; + +router.get('/pipeline', (req, res) => { + const items = getPipeline(); + res.json( + items.map(item => ({ + key: item.key, + service: item.service, + title: item.title, + subtitle: item.subtitle, + posterUrl: item.posterUrl, + stage: item.stage, + stageStartedAt: item.stageStartedAt || item.stageChangedAt || item.startedAt || item.createdAt, + startedAt: item.startedAt || item.createdAt, + stuckReason: item.stuckReason || item.error, + stuckAt: item.stuckAt, + canRetry: item.canRetry || false, + progress: item.progress, + speed: item.speed, + eta: item.eta, + logId: item.logId, + steps: item.steps || [], + seriesId: item.seriesId || null, + movieId: item.movieId || null, + artistId: item.artistId || null, + seasonNumbers: item.seasonNumbers || null, + queueId: item.queueId || null, + retryId: item.retryId || null, + })), + ); +}); + +router.delete('/pipeline/:key', (req, res) => { + removePipelineItem(req.params.key); + res.json({ success: true }); +}); + +router.post('/pipeline/:key/retry', async (req, res) => { + const item = getPipelineItem(req.params.key); + if (!item) return res.status(404).json({ error: 'Pipeline item not found' }); + try { + if (item.service === 'sonarr' && item.retryId && SONARR_API_KEY) { + const cmdBody = item.retrySeasonNumbers?.length + ? { name: 'SeasonSearch', seriesId: item.retryId, seasonNumber: item.retrySeasonNumbers[0] } + : { name: 'SeriesSearch', seriesId: item.retryId }; + const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(cmdBody), + }); + if (!cmdResp.ok) throw new Error(`Sonarr command failed: HTTP ${cmdResp.status}`); + const cmdData = await cmdResp.json(); + advancePipeline(item.key, 'searching'); + if (item.logId) addLogStep(item.logId, 'Retrying search…', 'info'); + watchSonarrSearch( + item.key, + cmdData.id, + item.retryId, + item.retrySeasonNumbers?.[0] ?? null, + item.logId, + item.title, + ).catch(e => console.error('watchSonarrSearch retry:', e.message)); + } else if (item.service === 'radarr' && item.retryId && RADARR_API_KEY) { + const cmdResp = await fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'MoviesSearch', movieIds: [item.retryId] }), + }); + if (!cmdResp.ok) throw new Error(`Radarr command failed: HTTP ${cmdResp.status}`); + const cmdData = await cmdResp.json(); + advancePipeline(item.key, 'searching'); + if (item.logId) addLogStep(item.logId, 'Retrying search…', 'info'); + watchRadarrSearch(item.key, cmdData.id, item.retryId, item.logId, item.title).catch(e => + console.error('watchRadarrSearch retry:', e.message), + ); + } else { + return res.status(400).json({ error: 'Cannot retry this item type' }); + } + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.delete('/pipeline/:key/cancel', async (req, res) => { + const item = getPipelineItem(req.params.key); + if (!item) return res.status(404).json({ error: 'Not found' }); + try { + if (item.torrentHash) { + try { + const { qbHost, cookie } = await qbittorrentLogin(); + await fetch(`${qbHost}/api/v2/torrents/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie }, + body: `hashes=${item.torrentHash}&deleteFiles=true`, + }); + } catch {} + } + if (item.queueId) { + try { + if (item.service === 'sonarr' && SONARR_API_KEY) { + await fetch( + `${SONARR_HOST}/api/v3/queue/${item.queueId}?removeFromClient=true&blocklist=false&apikey=${SONARR_API_KEY}`, + { method: 'DELETE' }, + ); + } else if (item.service === 'radarr' && RADARR_API_KEY) { + await fetch( + `${RADARR_HOST}/api/v3/queue/${item.queueId}?removeFromClient=true&blocklist=false&apikey=${RADARR_API_KEY}`, + { method: 'DELETE' }, + ); + } + } catch {} + } + removePipelineItem(req.params.key); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +router.post('/pipeline/:key/monitor', (req, res) => { + const item = getPipelineItem(req.params.key); + if (!item) return res.status(404).json({ error: 'Not found' }); + if (item.logId) addLogStep(item.logId, 'Set to monitor — will grab automatically when released', 'info'); + removePipelineItem(req.params.key); + res.json({ success: true }); +}); + +router.post('/command/search', async (req, res) => { + const { service, id, seasonNumbers, albumIds } = req.body; + if (!service) return res.status(400).json({ error: 'Missing service' }); + if (!id && service !== 'lidarr') return res.status(400).json({ error: 'Missing id' }); + + try { + if (service === 'sonarr' && SONARR_API_KEY) { + const seriesResp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`); + if (!seriesResp.ok) throw new Error(`Failed to fetch series from Sonarr: HTTP ${seriesResp.status}`); + const seriesData = await seriesResp.json(); + const seriesTitle = seriesData.title || 'Unknown'; + const seriesPosterUrl = pickArrImageUrl(seriesData.images || [], 'poster', 'sonarr'); + + let monitoringChanged = false; + if (!seriesData.monitored) { + seriesData.monitored = true; + monitoringChanged = true; + } + for (const season of seriesData.seasons || []) { + if (season.seasonNumber === 0) continue; + const targeted = seasonNumbers?.length ? seasonNumbers.includes(season.seasonNumber) : true; + if (targeted && !season.monitored) { + season.monitored = true; + monitoringChanged = true; + } + } + if (monitoringChanged) { + const putResp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(seriesData), + }); + if (!putResp.ok) console.error(`Failed to enable monitoring for series ${id}: HTTP ${putResp.status}`); + } + + if (seasonNumbers?.length) { + for (const sn of seasonNumbers) { + const snLabel = seasonNumbers.length > 1 ? `${seriesTitle} S${sn}` : `${seriesTitle} Season ${sn}`; + const logId = logActivity('download', `Searching: ${snLabel}`, { seriesId: id, season: sn }, 'pending', { + service: 'sonarr', + seriesId: id, + season: sn, + title: seriesTitle, + }); + if (monitoringChanged) addLogStep(logId, `Auto-enabled monitoring for ${snLabel}`, 'info'); + const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'SeasonSearch', seriesId: id, seasonNumber: sn }), + }); + if (!cmdResp.ok) throw new Error(`Sonarr command failed: HTTP ${cmdResp.status}`); + const cmdData = await cmdResp.json(); + updateLogEntry(logId, { status: 'info', message: `Sonarr searching: ${snLabel}` }); + const pendingKey = `sonarr-${id}-${sn}-${Date.now()}`; + pendingSearches.set(pendingKey, { + key: pendingKey, + service: 'sonarr', + title: seriesTitle, + subtitle: `Season ${sn}`, + seasons: [sn], + logId, + seriesId: id, + posterUrl: seriesPosterUrl, + startedAt: Date.now(), + status: 'searching', + }); + addPipelineItem(pendingKey, { + service: 'sonarr', + title: seriesTitle, + subtitle: `Season ${sn}`, + posterUrl: seriesPosterUrl, + logId, + seriesId: id, + seasonNumbers: [sn], + retryId: id, + }); + advancePipeline(pendingKey, 'searching'); + watchSonarrSearch(pendingKey, cmdData.id, id, sn, logId, seriesTitle).catch(e => + console.error('watchSonarrSearch:', e.message), + ); + } + } else { + const logId = logActivity('download', `Searching: ${seriesTitle}`, { seriesId: id }, 'pending', { + service: 'sonarr', + seriesId: id, + title: seriesTitle, + }); + if (monitoringChanged) addLogStep(logId, `Auto-enabled monitoring for ${seriesTitle}`, 'info'); + const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }), + }); + if (!cmdResp.ok) throw new Error(`Sonarr command failed: HTTP ${cmdResp.status}`); + const cmdData = await cmdResp.json(); + updateLogEntry(logId, { status: 'info', message: `Sonarr searching: ${seriesTitle}` }); + const pendingKey = `sonarr-${id}-all-${Date.now()}`; + pendingSearches.set(pendingKey, { + key: pendingKey, + service: 'sonarr', + title: seriesTitle, + subtitle: 'All seasons', + seasons: null, + logId, + seriesId: id, + posterUrl: seriesPosterUrl, + startedAt: Date.now(), + status: 'searching', + }); + addPipelineItem(pendingKey, { + service: 'sonarr', + title: seriesTitle, + subtitle: 'All seasons', + posterUrl: seriesPosterUrl, + logId, + seriesId: id, + seasonNumbers: null, + retryId: id, + }); + advancePipeline(pendingKey, 'searching'); + watchSonarrSearch(pendingKey, cmdData.id, id, null, logId, seriesTitle).catch(e => + console.error('watchSonarrSearch:', e.message), + ); + } + res.json({ success: true }); + } else if (service === 'radarr' && RADARR_API_KEY) { + let movieTitle = 'Unknown'; + let moviePosterUrl = null; + try { + const movieResp = await fetch(`${RADARR_HOST}/api/v3/movie/${id}?apikey=${RADARR_API_KEY}`); + if (movieResp.ok) { + const movieData = await movieResp.json(); + movieTitle = movieData.title || 'Unknown'; + moviePosterUrl = pickArrImageUrl(movieData.images || [], 'poster', 'radarr'); + } + } catch {} + const logId = logActivity('download', `Searching for "${movieTitle}"`, { movieId: id }, 'pending', { + service: 'radarr', + title: movieTitle, + movieId: id, + }); + const pipelineKey = `radarr-${id}-${Date.now()}`; + addPipelineItem(pipelineKey, { + service: 'radarr', + title: movieTitle, + subtitle: 'Movie', + posterUrl: moviePosterUrl, + logId, + movieId: id, + retryId: id, + }); + advancePipeline(pipelineKey, 'searching'); + const resp = await fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'MoviesSearch', movieIds: [id] }), + }); + if (!resp.ok) throw new Error(`Radarr command failed: HTTP ${resp.status}`); + const cmdData = await resp.json(); + updateLogEntry(logId, { status: 'info', message: `Radarr searching for "${movieTitle}"` }); + pendingSearches.set(pipelineKey, { + key: pipelineKey, + title: movieTitle, + service: 'radarr', + type: 'movie', + startedAt: Date.now(), + status: 'searching', + }); + watchRadarrSearch(pipelineKey, cmdData.id, id, logId, movieTitle).catch(e => + console.error('watchRadarrSearch:', e.message), + ); + res.json({ success: true }); + } else if (service === 'lidarr' && LIDARR_API_KEY) { + if (albumIds?.length) { + let albumNames = []; + try { + const albumDetails = await Promise.all( + albumIds.map(aid => fetchWithTimeout(`${LIDARR_HOST}/api/v1/album/${aid}?apikey=${LIDARR_API_KEY}`)), + ); + albumNames = albumDetails.map(a => a.title || 'Unknown'); + } catch {} + const logId = logActivity( + 'download', + `Searching Lidarr for ${albumIds.length} album(s): ${albumNames.join(', ') || albumIds.join(', ')}`, + { albumIds, albumNames }, + 'pending', + { service: 'lidarr', albumNames, albumIds }, + ); + const resp = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'AlbumSearch', albumIds }), + }); + if (!resp.ok) throw new Error(`Lidarr command failed: HTTP ${resp.status}`); + updateLogEntry(logId, { + status: 'success', + message: `Lidarr searching for ${albumIds.length} album(s): ${albumNames.join(', ') || 'unknown'}`, + }); + } else { + const logId = logActivity('download', 'Searching Lidarr for artist', { artistId: id }, 'pending'); + const resp = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'ArtistSearch', artistId: id }), + }); + if (!resp.ok) throw new Error(`Lidarr command failed: HTTP ${resp.status}`); + updateLogEntry(logId, { status: 'success', message: 'Lidarr artist search triggered' }); + } + res.json({ success: true }); + } else { + return res.status(400).json({ error: 'Unknown service or not configured' }); + } + } catch (err) { + logActivity('error', `Search command failed: ${err.message}`, { service, id }, 'error'); + console.error('Command/search error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +router.post('/grab', async (req, res) => { + const { service, guid, indexerId, pipelineKey, downloadUrl, title } = req.body; + if (!guid && !downloadUrl) return res.status(400).json({ error: 'Missing guid or downloadUrl' }); + if (downloadUrl && !title) return res.status(400).json({ error: 'Missing title for downloadUrl grab' }); + const cfg = + service === 'radarr' && RADARR_API_KEY + ? { service: 'Radarr', host: RADARR_HOST, apiKey: RADARR_API_KEY } + : service === 'sonarr' && SONARR_API_KEY + ? { service: 'Sonarr', host: SONARR_HOST, apiKey: SONARR_API_KEY } + : null; + if (!cfg) return res.status(400).json({ error: 'Unknown service' }); + try { + await arrGrab({ ...cfg, guid, indexerId, downloadUrl, title }); + if (pipelineKey) advancePipeline(pipelineKey, 'grabbed'); + res.json({ success: true }); + } catch (err) { + console.error('Grab error:', err.message); + res.status(500).json({ error: err.message }); + } +}); + +router.get('/arr-queue', async (req, res) => { + const results = []; + const tasks = []; + if (SONARR_API_KEY) { + tasks.push( + (async () => { + try { + const data = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/queue?page=1&pageSize=100&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, + 8000, + ); + for (const item of data.records || []) { + const ep = item.episode + ? `S${String(item.episode.seasonNumber).padStart(2, '0')}E${String(item.episode.episodeNumber).padStart(2, '0')}` + : null; + const msgs = (item.statusMessages || []).map(m => m.title || (m.messages || []).join(', ')).filter(Boolean); + results.push({ + id: `sonarr-${item.id}`, + service: 'sonarr', + seriesId: item.seriesId || item.series?.id || null, + title: item.series?.title || 'Unknown', + episode: ep, + seasonNumber: item.episode?.seasonNumber, + status: item.status, + trackedStatus: item.trackedDownloadStatus, + progress: item.size > 0 ? Math.round((1 - item.sizeleft / item.size) * 100) : 0, + size: item.size, + sizeleft: item.sizeleft, + errorMessage: item.errorMessage || msgs.join('; ') || null, + addedAt: item.added, + posterUrl: pickArrImageUrl(item.series?.images || [], 'poster', 'sonarr'), + }); + } + } catch (err) { + console.error('arr-queue sonarr:', err.message); + } + })(), + ); + } + if (RADARR_API_KEY) { + tasks.push( + (async () => { + try { + const data = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/queue?page=1&pageSize=100&includeMovie=true&apikey=${RADARR_API_KEY}`, + 8000, + ); + for (const item of data.records || []) { + const msgs = (item.statusMessages || []).map(m => m.title || (m.messages || []).join(', ')).filter(Boolean); + results.push({ + id: `radarr-${item.id}`, + service: 'radarr', + movieId: item.movieId || item.movie?.id || null, + title: item.movie?.title || 'Unknown', + episode: null, + seasonNumber: null, + status: item.status, + trackedStatus: item.trackedDownloadStatus, + progress: item.size > 0 ? Math.round((1 - item.sizeleft / item.size) * 100) : 0, + size: item.size, + sizeleft: item.sizeleft, + errorMessage: item.errorMessage || msgs.join('; ') || null, + addedAt: item.added, + posterUrl: pickArrImageUrl(item.movie?.images || [], 'poster', 'radarr'), + }); + } + } catch (err) { + console.error('arr-queue radarr:', err.message); + } + })(), + ); + } + await Promise.allSettled(tasks); + res.json(results); +}); + +router.get('/pending-searches', (req, res) => { + res.json( + [...pendingSearches.values()].map(s => ({ + key: s.key, + service: s.service, + title: s.title, + subtitle: s.subtitle, + seasons: s.seasons, + startedAt: s.startedAt, + status: s.status, + posterUrl: s.posterUrl, + error: s.error || null, + })), + ); +}); + +export default router; diff --git a/backend/src/pipeline/index.js b/backend/src/pipeline/index.js new file mode 100644 index 0000000..26ac9c5 --- /dev/null +++ b/backend/src/pipeline/index.js @@ -0,0 +1,6 @@ +import './bootstrap.js'; + +export { arrGrab } from './grab.js'; +export { monitorQueues } from './queueMonitor.js'; +export { watchRadarrSearch, watchSonarrSearch } from './searchWatchers.js'; +export { default } from './handlers.js'; diff --git a/backend/src/pipeline/pendingSearches.js b/backend/src/pipeline/pendingSearches.js new file mode 100644 index 0000000..4df39b2 --- /dev/null +++ b/backend/src/pipeline/pendingSearches.js @@ -0,0 +1,2 @@ +// Transient watcher state lingers past pipeline updates so /pending-searches can expose terminal search outcomes. +export const pendingSearches = new Map(); diff --git a/backend/src/pipeline/queueMonitor.js b/backend/src/pipeline/queueMonitor.js new file mode 100644 index 0000000..a52a6a8 --- /dev/null +++ b/backend/src/pipeline/queueMonitor.js @@ -0,0 +1,292 @@ +import { CONFIG } from '../config.js'; +import { fetchWithTimeout } from '../utils.js'; +import { + getPipeline, + getPipelineItem, + advancePipeline, + completePipeline, + setPipelineStuck, + addLogStep, + updateLogEntry, + logActivity, + getActivityLog, +} from '../state.js'; + +const { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY } = CONFIG; + +const queueAlerts = new Map(); + +export async function monitorQueues() { + const tasks = []; + + if (LIDARR_API_KEY) { + tasks.push( + (async () => { + try { + const data = await fetchWithTimeout( + `${LIDARR_HOST}/api/v1/queue?page=1&pageSize=100&includeArtist=true&includeAlbum=true&apikey=${LIDARR_API_KEY}`, + 8000, + ); + const lidarrCounts = new Map(); + for (const r of data.records || []) { + const dlid = r.downloadId || `__id_${r.id}`; + lidarrCounts.set(dlid, (lidarrCounts.get(dlid) || 0) + 1); + } + const seenLidarr = new Set(); + for (const item of data.records || []) { + const dlid = item.downloadId || `__id_${item.id}`; + if (seenLidarr.has(dlid)) continue; + seenLidarr.add(dlid); + const key = `lidarr-q-${dlid}`; + const artist = item.artist?.artistName || 'Unknown'; + const album = item.album?.title || 'Unknown'; + const trackCount = lidarrCounts.get(dlid) || 1; + const label = trackCount > 1 ? `${artist} - ${album} (${trackCount} tracks)` : `${artist} - ${album}`; + const hasError = + item.status === 'warning' || + item.status === 'error' || + item.trackedDownloadStatus === 'warning' || + item.trackedDownloadStatus === 'error' || + item.statusMessages?.length > 0; + const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); + const errorDetail = item.errorMessage || msgs.join('; ') || ''; + + if (hasError && !queueAlerts.has(key)) { + const errorDetails = { + status: item.status, + trackedDownloadStatus: item.trackedDownloadStatus, + trackedDownloadState: item.trackedDownloadState, + protocol: item.protocol, + errorMessage: item.errorMessage || null, + statusMessages: (item.statusMessages || []).map(m => ({ + title: m.title, + messages: m.messages || [], + })), + }; + const logId = logActivity( + 'queue', + `${label}: ${errorDetail || 'download issue'}`, + errorDetails, + 'error', + { service: 'lidarr', artistName: artist, title: album }, + ); + queueAlerts.set(key, { logId, status: 'error' }); + } else if (!hasError && queueAlerts.has(key)) { + const alert = queueAlerts.get(key); + updateLogEntry(alert.logId, { status: 'success', message: `${label}: resolved` }); + queueAlerts.delete(key); + } + + if (item.status === 'downloading' && item.trackedDownloadStatus === 'ok') { + const pKey = `lidarr-dl-${dlid}`; + if (!queueAlerts.has(pKey)) { + const protocol = item.protocol === 'torrent' ? 'torrent' : 'Soulseek'; + const logId = logActivity( + 'download', + `Downloading ${label} via ${protocol}`, + { service: 'lidarr', queueId: item.id, downloadId: item.downloadId, protocol: item.protocol }, + 'pending', + { service: 'lidarr', artistName: artist, title: album }, + ); + queueAlerts.set(pKey, { logId, status: 'pending' }); + } + } + } + + const activeLidarrDlids = new Set((data.records || []).map(r => r.downloadId || `__id_${r.id}`)); + for (const [key, alert] of queueAlerts) { + if (key.startsWith('lidarr-dl-') && alert.status === 'pending') { + const trackedDlid = key.replace('lidarr-dl-', ''); + const stillInQueue = activeLidarrDlids.has(trackedDlid); + if (!stillInQueue) { + const logEntry = getActivityLog().find(e => e.id === alert.logId); + updateLogEntry(alert.logId, { + status: 'success', + message: logEntry?.message?.replace('Downloading', 'Downloaded') || 'Download completed', + }); + queueAlerts.delete(key); + } + } + } + } catch (err) { + console.error('Lidarr queue monitor:', err.message); + } + })(), + ); + } + + if (SONARR_API_KEY) { + tasks.push( + (async () => { + try { + const data = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/queue?page=1&pageSize=100&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, + 8000, + ); + const sonarrCounts = new Map(); + for (const r of data.records || []) { + const dlid = r.downloadId || `__id_${r.id}`; + sonarrCounts.set(dlid, (sonarrCounts.get(dlid) || 0) + 1); + } + const seenSonarr = new Set(); + for (const item of data.records || []) { + const dlid = item.downloadId || `__id_${item.id}`; + const isFirstForDlid = !seenSonarr.has(dlid); + if (isFirstForDlid) seenSonarr.add(dlid); + const key = `sonarr-q-${dlid}`; + const series = item.series?.title || 'Unknown'; + const ep = item.episode + ? `S${String(item.episode.seasonNumber).padStart(2, '0')}E${String(item.episode.episodeNumber).padStart(2, '0')}` + : ''; + const epCount = sonarrCounts.get(dlid) || 1; + const label = epCount > 1 ? `${series} (${epCount} episodes)` : `${series} ${ep}`.trim(); + const hasError = + item.status === 'warning' || + item.status === 'error' || + item.trackedDownloadStatus === 'warning' || + item.trackedDownloadStatus === 'error' || + item.statusMessages?.length > 0; + const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); + const errorDetail = item.errorMessage || msgs.join('; ') || ''; + + if (isFirstForDlid && hasError && !queueAlerts.has(key)) { + const logId = logActivity( + 'queue', + `${label}: ${errorDetail || 'download issue'}`, + { service: 'sonarr', queueId: item.id, downloadId: item.downloadId, recordCount: epCount }, + 'error', + { service: 'sonarr', title: series }, + ); + queueAlerts.set(key, { logId, status: 'error' }); + } else if (isFirstForDlid && !hasError && queueAlerts.has(key)) { + const alert = queueAlerts.get(key); + updateLogEntry(alert.logId, { status: 'success', message: `${label}: resolved` }); + queueAlerts.delete(key); + } + + const queueSeriesId = item.seriesId || item.series?.id; + const pipelineItems = getPipeline(); + for (const pItem of pipelineItems) { + if (pItem.service !== 'sonarr' || !pItem.seriesId || pItem.seriesId !== queueSeriesId) continue; + // Queue reconciliation can reattach cards when the grab transition was missed or delayed. + if (pItem.stage === 'grabbed' || pItem.stage === 'searching' || pItem.stage === 'stuck') { + const progress = + item.size > 0 && item.sizeleft != null ? Math.round((1 - item.sizeleft / item.size) * 100) : 0; + advancePipeline(pItem.key, 'downloading', { queueId: item.id, progress }); + } else if (pItem.stage === 'downloading' && pItem.queueId === item.id) { + const progress = + item.size > 0 && item.sizeleft != null + ? Math.round((1 - item.sizeleft / item.size) * 100) + : pItem.progress; + const updated = getPipelineItem(pItem.key); + if (updated) updated.progress = progress; + if (hasError) { + const statusMsgs = (item.statusMessages || []).flatMap(m => m.messages || [m.title]).filter(Boolean); + setPipelineStuck(pItem.key, statusMsgs.join(' ') || 'Download issue — check Sonarr for details'); + if (pItem.logId) addLogStep(pItem.logId, `Queue warning: ${statusMsgs.join(' ')}`, 'warning'); + } + } + } + } + + const pipelineItems = getPipeline(); + for (const pItem of pipelineItems) { + if (pItem.service !== 'sonarr' || pItem.stage !== 'downloading' || !pItem.queueId) continue; + const stillInQueue = (data.records || []).some(r => r.id === pItem.queueId); + if (!stillInQueue) { + // Queue disappearance is the earliest reliable handoff from downloading to import. + advancePipeline(pItem.key, 'importing'); + if (pItem.logId) addLogStep(pItem.logId, 'Download complete — importing to library', 'success'); + setTimeout(() => completePipeline(pItem.key), 3 * 60 * 1000); + } + } + } catch (err) { + console.error('Sonarr queue monitor:', err.message); + } + })(), + ); + } + + if (RADARR_API_KEY) { + tasks.push( + (async () => { + try { + const data = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/queue?page=1&pageSize=100&includeMovie=true&apikey=${RADARR_API_KEY}`, + 8000, + ); + const seenRadarr = new Set(); + for (const item of data.records || []) { + const dlid = item.downloadId || `__id_${item.id}`; + const isFirstForDlid = !seenRadarr.has(dlid); + if (isFirstForDlid) seenRadarr.add(dlid); + const key = `radarr-q-${dlid}`; + const title = item.movie?.title || 'Unknown'; + const hasError = + item.status === 'warning' || + item.status === 'error' || + item.trackedDownloadStatus === 'warning' || + item.trackedDownloadStatus === 'error' || + item.statusMessages?.length > 0; + const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); + const errorDetail = item.errorMessage || msgs.join('; ') || ''; + + if (isFirstForDlid && hasError && !queueAlerts.has(key)) { + const logId = logActivity( + 'queue', + `${title}: ${errorDetail || 'download issue'}`, + { service: 'radarr', queueId: item.id, downloadId: item.downloadId }, + 'error', + { service: 'radarr', title }, + ); + queueAlerts.set(key, { logId, status: 'error' }); + } else if (isFirstForDlid && !hasError && queueAlerts.has(key)) { + const alert = queueAlerts.get(key); + updateLogEntry(alert.logId, { status: 'success', message: `${title}: resolved` }); + queueAlerts.delete(key); + } + + const queueMovieId = item.movieId || item.movie?.id; + const pipelineItems = getPipeline(); + for (const pItem of pipelineItems) { + if (pItem.service !== 'radarr' || !pItem.movieId || pItem.movieId !== queueMovieId) continue; + if (pItem.stage === 'grabbed' || pItem.stage === 'searching' || pItem.stage === 'stuck') { + const progress = + item.size > 0 && item.sizeleft != null ? Math.round((1 - item.sizeleft / item.size) * 100) : 0; + advancePipeline(pItem.key, 'downloading', { queueId: item.id, progress }); + if (pItem.logId) addLogStep(pItem.logId, 'Release grabbed — now downloading', 'success'); + } else if (pItem.stage === 'downloading' && pItem.queueId === item.id) { + const progress = + item.size > 0 && item.sizeleft != null + ? Math.round((1 - item.sizeleft / item.size) * 100) + : pItem.progress; + const updated = getPipelineItem(pItem.key); + if (updated) updated.progress = progress; + if (hasError) { + const statusMsgs = (item.statusMessages || []).flatMap(m => m.messages || [m.title]).filter(Boolean); + setPipelineStuck(pItem.key, statusMsgs.join(' ') || 'Download issue — check Radarr for details'); + if (pItem.logId) addLogStep(pItem.logId, `Queue warning: ${statusMsgs.join(' ')}`, 'warning'); + } + } + } + } + + const pipelineItems = getPipeline(); + for (const pItem of pipelineItems) { + if (pItem.service !== 'radarr' || pItem.stage !== 'downloading' || !pItem.queueId) continue; + const stillInQueue = (data.records || []).some(r => r.id === pItem.queueId); + if (!stillInQueue) { + advancePipeline(pItem.key, 'importing'); + if (pItem.logId) addLogStep(pItem.logId, 'Download complete — importing to library', 'success'); + setTimeout(() => completePipeline(pItem.key), 3 * 60 * 1000); + } + } + } catch (err) { + console.error('Radarr queue monitor:', err.message); + } + })(), + ); + } + + await Promise.allSettled(tasks); +} diff --git a/backend/src/pipeline/searchWatchers.js b/backend/src/pipeline/searchWatchers.js new file mode 100644 index 0000000..b9e4b18 --- /dev/null +++ b/backend/src/pipeline/searchWatchers.js @@ -0,0 +1,237 @@ +import { CONFIG } from '../config.js'; +import { fetchWithTimeout } from '../utils.js'; +import { + getPipelineItem, + advancePipeline, + setPipelineStuck, + addLogStep, +} from '../state.js'; +import { STUCK_REASONS } from './constants.js'; +import { pendingSearches } from './pendingSearches.js'; +import { addPipelineStep, fetchRejectionSummary } from './utils.js'; + +const { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY } = CONFIG; + +export async function watchSonarrSearch(pendingKey, commandId, seriesId, seasonNumber, logId, seriesTitle) { + const searchStart = Date.now(); + const deadline = searchStart + 4 * 60 * 1000; + + addPipelineStep(pendingKey, 'Sonarr command submitted — polling for completion…'); + + let commandCompleted = false; + let firstPoll = true; + while (Date.now() < deadline) { + if (!firstPoll) await new Promise(r => setTimeout(r, 15000)); + firstPoll = false; + try { + const cmd = await fetchWithTimeout(`${SONARR_HOST}/api/v3/command/${commandId}?apikey=${SONARR_API_KEY}`, 8000); + if (cmd.state === 'failed') { + const msg = cmd.exception || 'Search command failed'; + addLogStep(logId, `Sonarr search failed: ${msg}`, 'error'); + const entry = pendingSearches.get(pendingKey); + if (entry) Object.assign(entry, { status: 'error', error: msg }); + setPipelineStuck(pendingKey, msg); + const item = getPipelineItem(pendingKey); + if (item) item.canRetry = true; + setTimeout(() => pendingSearches.delete(pendingKey), 30000); + return; + } + if (cmd.state === 'completed') { + commandCompleted = true; + break; + } + } catch { + /* continue polling */ + } + } + + if (!commandCompleted) { + const timeoutMsg = 'Sonarr search command timed out — indexers may be slow or unavailable'; + addLogStep(logId, timeoutMsg, 'warning'); + addPipelineStep(pendingKey, timeoutMsg); + const entry = pendingSearches.get(pendingKey); + if (entry) Object.assign(entry, { status: 'no_results' }); + setPipelineStuck(pendingKey, timeoutMsg); + const item = getPipelineItem(pendingKey); + if (item) item.canRetry = true; + setTimeout(() => pendingSearches.delete(pendingKey), 120000); + return; + } + + addPipelineStep(pendingKey, 'Sonarr finished querying indexers — waiting for grab history…'); + // Sonarr can finish the search command before the matching grab shows up in history. + let grabs = []; + const historyDeadline = Date.now() + 30000; + while (Date.now() < historyDeadline && grabs.length === 0) { + await new Promise(r => setTimeout(r, 3000)); + try { + const histCheck = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/history?pageSize=50&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, + 8000, + ); + grabs = (histCheck.records || []).filter(h => { + if (h.eventType !== 'grabbed') return false; + if (new Date(h.date).getTime() < searchStart) return false; + if (h.seriesId !== seriesId) return false; + if (seasonNumber != null && h.episode?.seasonNumber !== seasonNumber) return false; + return true; + }); + } catch { + /* continue polling */ + } + } + + if (grabs.length === 0) { + try { + const history = await fetchWithTimeout( + `${SONARR_HOST}/api/v3/history?pageSize=50&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, + 8000, + ); + grabs = (history.records || []).filter(h => { + if (h.eventType !== 'grabbed') return false; + if (new Date(h.date).getTime() < searchStart) return false; + if (h.seriesId !== seriesId) return false; + if (seasonNumber != null && h.episode?.seasonNumber !== seasonNumber) return false; + return true; + }); + } catch { + /* ignore */ + } + } + + try { + const entry = pendingSearches.get(pendingKey); + if (grabs.length > 0) { + const titles = grabs + .map(g => g.sourceTitle || 'release') + .slice(0, 2) + .join(', '); + addLogStep(logId, `Grabbed ${grabs.length} episode(s): ${titles}`, 'success'); + addPipelineStep(pendingKey, `Grabbed ${grabs.length} release(s) — sending to qBittorrent`); + if (entry) Object.assign(entry, { status: 'grabbed' }); + advancePipeline(pendingKey, 'grabbed'); + setTimeout(() => pendingSearches.delete(pendingKey), 90000); + } else { + const releaseUrl = + seasonNumber != null + ? `${SONARR_HOST}/api/v3/release?seriesId=${seriesId}&seasonNumber=${seasonNumber}&apikey=${SONARR_API_KEY}` + : `${SONARR_HOST}/api/v3/release?seriesId=${seriesId}&apikey=${SONARR_API_KEY}`; + const rejSummary = await fetchRejectionSummary(releaseUrl); + const noResultsMsg = rejSummary + ? `No grab — ${rejSummary}` + : 'No matching releases found — check indexers or episode availability'; + addLogStep(logId, noResultsMsg, rejSummary ? 'warning' : 'warning'); + addPipelineStep(pendingKey, noResultsMsg); + if (entry) Object.assign(entry, { status: 'no_results' }); + setPipelineStuck(pendingKey, rejSummary || STUCK_REASONS.searching_no_results); + const item = getPipelineItem(pendingKey); + if (item) item.canRetry = true; + setTimeout(() => pendingSearches.delete(pendingKey), 120000); + } + } catch (err) { + console.error('watchSonarrSearch history check failed:', err.message); + setTimeout(() => pendingSearches.delete(pendingKey), 60000); + } +} + +export async function watchRadarrSearch(pipelineKey, commandId, movieId, logId, movieTitle) { + const searchStart = Date.now(); + const deadline = searchStart + 4 * 60 * 1000; + + addPipelineStep(pipelineKey, 'Radarr command submitted — polling for completion…'); + + let commandCompleted = false; + let firstPoll = true; + while (Date.now() < deadline) { + if (!firstPoll) await new Promise(r => setTimeout(r, 15000)); + firstPoll = false; + try { + const cmd = await fetchWithTimeout(`${RADARR_HOST}/api/v3/command/${commandId}?apikey=${RADARR_API_KEY}`, 8000); + if (cmd.state === 'failed') { + const msg = cmd.exception || 'Radarr search command failed'; + setPipelineStuck(pipelineKey, msg); + const item = getPipelineItem(pipelineKey); + if (item) item.canRetry = true; + if (logId) addLogStep(logId, `Radarr search failed: ${msg}`, 'error'); + return; + } + if (cmd.state === 'completed') { + commandCompleted = true; + break; + } + } catch { + /* continue polling */ + } + } + + if (!commandCompleted) { + const timeoutMsg = 'Radarr search command timed out — indexers may be slow or unavailable'; + if (logId) addLogStep(logId, timeoutMsg, 'warning'); + addPipelineStep(pipelineKey, timeoutMsg); + setPipelineStuck(pipelineKey, timeoutMsg); + const item = getPipelineItem(pipelineKey); + if (item) item.canRetry = true; + return; + } + + addPipelineStep(pipelineKey, 'Radarr finished querying indexers — waiting for grab history…'); + // Radarr can finish the search command before the matching grab shows up in history. + let grabs = []; + const historyDeadline = Date.now() + 30000; + while (Date.now() < historyDeadline && grabs.length === 0) { + await new Promise(r => setTimeout(r, 3000)); + try { + const histCheck = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/history?pageSize=50&includeMovie=true&apikey=${RADARR_API_KEY}`, + 8000, + ); + grabs = (histCheck.records || []).filter(h => { + if (h.eventType !== 'grabbed') return false; + if (new Date(h.date).getTime() < searchStart) return false; + if (h.movieId !== movieId) return false; + return true; + }); + } catch { + /* continue polling */ + } + } + + if (grabs.length === 0) { + try { + const history = await fetchWithTimeout( + `${RADARR_HOST}/api/v3/history?pageSize=50&includeMovie=true&apikey=${RADARR_API_KEY}`, + 8000, + ); + grabs = (history.records || []).filter(h => { + if (h.eventType !== 'grabbed') return false; + if (new Date(h.date).getTime() < searchStart) return false; + if (h.movieId !== movieId) return false; + return true; + }); + } catch { + /* ignore */ + } + } + + try { + if (grabs.length > 0) { + const sourceTitle = grabs[0].sourceTitle || 'release'; + addPipelineStep(pipelineKey, `Grabbed: ${sourceTitle} — sending to qBittorrent`); + advancePipeline(pipelineKey, 'grabbed'); + if (logId) addLogStep(logId, `Grabbed: ${sourceTitle}`, 'success'); + } else { + const releaseUrl = `${RADARR_HOST}/api/v3/release?movieId=${movieId}&apikey=${RADARR_API_KEY}`; + const rejSummary = await fetchRejectionSummary(releaseUrl); + const noResultsMsg = rejSummary + ? `No grab — ${rejSummary}` + : 'No matching releases found — check indexers or availability'; + addPipelineStep(pipelineKey, noResultsMsg); + setPipelineStuck(pipelineKey, rejSummary || STUCK_REASONS.searching_no_results); + const item = getPipelineItem(pipelineKey); + if (item) item.canRetry = true; + if (logId) addLogStep(logId, noResultsMsg, 'warning'); + } + } catch (err) { + console.error('watchRadarrSearch history check failed:', err.message); + } +} diff --git a/backend/src/pipeline/stuckCheck.js b/backend/src/pipeline/stuckCheck.js new file mode 100644 index 0000000..603a823 --- /dev/null +++ b/backend/src/pipeline/stuckCheck.js @@ -0,0 +1,39 @@ +import { + getPipeline, + getPipelineItem, + removePipelineItem, + setPipelineStuck, + addLogStep, +} from '../state.js'; +import { STUCK_THRESHOLDS, STUCK_REASONS } from './constants.js'; + +export function checkPipelineStuck() { + try { + const now = Date.now(); + const items = getPipeline(); + for (const item of items) { + if (item.stage === 'complete' || item.stage === 'failed') continue; + if (item.stage === 'stuck' && item.stuckAt && now - item.stuckAt > 30 * 60 * 1000) { + // Leave stuck cards visible for manual retry/debug before aging them out separately. + removePipelineItem(item.key); + continue; + } + if (item.stage === 'stuck') continue; + const threshold = STUCK_THRESHOLDS[item.stage]; + if (!threshold) continue; + const stageStartedAt = item.stageStartedAt || item.stageChangedAt || item.startedAt || item.createdAt || now; + if (now - stageStartedAt > threshold) { + const reason = STUCK_REASONS[`${item.stage}_timeout`] || `Taking longer than expected at stage: ${item.stage}`; + setPipelineStuck(item.key, reason); + const i = getPipelineItem(item.key); + if (i) { + i.canRetry = item.stage === 'searching' || item.stage === 'grabbed'; + i.stuckAt = Date.now(); + } + if (item.logId) addLogStep(item.logId, `Stuck at "${item.stage}": ${reason}`, 'warning'); + } + } + } catch (e) { + console.error('[checkPipelineStuck] error:', e.message); + } +} diff --git a/backend/src/pipeline/utils.js b/backend/src/pipeline/utils.js new file mode 100644 index 0000000..0fd76d5 --- /dev/null +++ b/backend/src/pipeline/utils.js @@ -0,0 +1,49 @@ +import { fetchWithTimeout } from '../utils.js'; +import { getPipelineItem } from '../state.js'; + +export async function fetchRejectionSummary(releaseUrl) { + try { + const releases = await fetchWithTimeout(releaseUrl, 30000); + if (!Array.isArray(releases) || releases.length === 0) return null; + const rejected = releases.filter(r => r.rejected); + const approved = releases.filter(r => !r.rejected); + if (approved.length > 0) { + return `${approved.length} release${approved.length !== 1 ? 's' : ''} found — may be grabbing now, check downloads`; + } + const counts = {}; + for (const r of rejected) { + for (const rej of r.rejections || []) { + const cat = rej.includes('alias') + ? 'title alias conflict' + : rej.includes('seeders') + ? 'no seeders' + : rej.includes('not wanted in profile') + ? 'quality profile mismatch' + : rej.includes('Unknown') + ? 'unrecognized release' + : rej.includes('Wrong season') + ? 'wrong season' + : rej.includes('Existing file') + ? 'already at cutoff quality' + : rej.includes('Episode wasn') + ? 'episode not monitored' + : 'other'; + counts[cat] = (counts[cat] || 0) + 1; + } + } + const parts = Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([k, v]) => `${v} ${k}`); + return `${rejected.length} found, all rejected — ${parts.join(', ')}`; + } catch { + return null; + } +} + +export function addPipelineStep(key, message) { + const item = getPipelineItem(key); + if (!item) return; + if (!item.steps) item.steps = []; + item.steps.push({ ts: Date.now(), message }); +} diff --git a/backend/src/routes/activity.js b/backend/src/routes/activity.js index dde34f5..cd0eb65 100644 --- a/backend/src/routes/activity.js +++ b/backend/src/routes/activity.js @@ -1,16 +1,21 @@ import { Router } from 'express'; -import { getActivityFeed, getActivityLog, persistActivityLog } from '../state.js'; +import { getActivityLog } from '../state.js'; const router = Router(); router.get('/activity-log', (req, res) => { - const entries = getActivityFeed({ - since: req.query.since, - limit: req.query.limit, - includeHidden: req.query.includeHidden === 'true', - }); + const activityLog = getActivityLog(); + const since = req.query.since; + const limit = Math.min(parseInt(req.query.limit) || 50, 100); + let entries = activityLog; + if (since) { + const sinceDate = new Date(since); + if (!isNaN(sinceDate.getTime())) { + entries = entries.filter(e => new Date(e.timestamp) > sinceDate); + } + } // Polling clients only need summary rows here; detailed steps stay on the per-entry route. - const slim = entries.map(e => ({ + const slim = entries.slice(0, limit).map(e => ({ ...e, stepCount: e.steps?.length || 0, steps: undefined, @@ -28,7 +33,6 @@ router.get('/activity-log/:id', (req, res) => { router.delete('/activity-log', (req, res) => { const activityLog = getActivityLog(); activityLog.length = 0; - persistActivityLog(); res.json({ success: true }); }); @@ -36,7 +40,6 @@ router.delete('/activity-log/:id', (req, res) => { const activityLog = getActivityLog(); const idx = activityLog.findIndex(e => e.id === req.params.id); if (idx >= 0) activityLog.splice(idx, 1); - persistActivityLog(); res.json({ success: true }); }); diff --git a/backend/src/routes/health.js b/backend/src/routes/health.js index d5a52fa..460ecfb 100644 --- a/backend/src/routes/health.js +++ b/backend/src/routes/health.js @@ -4,150 +4,42 @@ import http from 'http'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { CONFIG } from '../config.js'; -import { - fetchWithTimeout, - hasQbittorrentCredentials, - hasText, - normalizeServiceUrl, - probeQbittorrentVersion, - qbFetchText, -} from '../utils.js'; -import { readInstallerState } from '../installerState.js'; +import { fetchWithTimeout } from '../utils.js'; const router = Router(); const execFileAsync = promisify(execFile); -const docker = new Docker({ socketPath: '/var/run/docker.sock' }); +const docker = new Docker({ socketPath: CONFIG.DOCKER_SOCKET }); let storageCache = null; let storageCacheTime = 0; +// These shell-outs hit host mounts, so a short cache keeps the dashboard from thrashing disk. const STORAGE_CACHE_TTL = 60000; -const LIBRARY_SERVICE_NAMES = ['radarr', 'sonarr', 'lidarr']; -const COMPLETE_SETUP_VALUES = new Set(['complete', 'completed', 'done', 'installed', 'ready', 'success']); -const IN_PROGRESS_SETUP_VALUES = new Set(['installing', 'configuring', 'starting', 'bootstrapping', 'running', 'pending']); -const FAILED_SETUP_VALUES = new Set(['failed', 'error', 'blocked']); - -function readNestedValue(source, path) { - return path.split('.').reduce((value, key) => (value && typeof value === 'object' ? value[key] : undefined), source); -} - -function firstMatchingValue(source, paths, predicate) { - for (const path of paths) { - const value = readNestedValue(source, path); - if (predicate(value)) return value; - } - return undefined; -} - -function inferInstallerProgress(installerState) { - const phase = firstMatchingValue(installerState, [ - 'phase', - 'setupPhase', - 'setup.phase', - 'progress.phase', - 'state.phase', - ], hasText) || null; - const statusText = firstMatchingValue(installerState, [ - 'status', - 'setup.status', - 'progress.status', - 'state.status', - ], hasText) || null; - const normalizedPhase = phase?.trim().toLowerCase() || null; - const normalizedStatus = statusText?.trim().toLowerCase() || null; - - const explicitComplete = firstMatchingValue(installerState, [ - 'setupComplete', - 'complete', - 'completed', - 'setup.complete', - 'setup.completed', - 'progress.complete', - 'progress.completed', - ], value => typeof value === 'boolean'); - const explicitFailed = firstMatchingValue(installerState, [ - 'failed', - 'setup.failed', - 'progress.failed', - 'hasError', - 'setup.hasError', - ], value => typeof value === 'boolean'); - const explicitInProgress = firstMatchingValue(installerState, [ - 'inProgress', - 'setup.inProgress', - 'progress.inProgress', - 'setupRunning', - ], value => typeof value === 'boolean'); - - return { - phase: phase || statusText, - explicitComplete: explicitComplete ?? ( - COMPLETE_SETUP_VALUES.has(normalizedPhase) || COMPLETE_SETUP_VALUES.has(normalizedStatus) - ? true - : undefined - ), - explicitFailed: explicitFailed ?? ( - FAILED_SETUP_VALUES.has(normalizedPhase) || FAILED_SETUP_VALUES.has(normalizedStatus) - ? true - : undefined - ), - explicitInProgress: explicitInProgress ?? ( - IN_PROGRESS_SETUP_VALUES.has(normalizedPhase) || IN_PROGRESS_SETUP_VALUES.has(normalizedStatus) - ? true - : undefined - ), - lastError: hasText(installerState?.lastInstallError) ? installerState.lastInstallError : null, - }; -} - -function incrementSummaryBucket(bucket, status) { - bucket[status] = (bucket[status] || 0) + 1; -} - -async function resolveServiceStatus(definition) { - const expectedBySetup = definition.expectedBySetup === true; - const requiredForSetup = definition.requiredForSetup === true; - const base = { - url: normalizeServiceUrl(definition.url), - configured: definition.configured === true, - healthy: false, - reachable: null, - optional: !expectedBySetup && !requiredForSetup, - expectedBySetup, - requiredForSetup, - }; +function serviceOrigin(url) { try { - return { - ...base, - ...(await definition.probe()), - }; - } catch (error) { - return { - ...base, - status: 'down', - reason: 'unreachable', - reachable: false, - error: error.message, - }; + return new URL(url).origin; + } catch { + return url; } } -// These shell-outs hit host mounts, so a short cache keeps the dashboard from thrashing disk. router.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); router.get('/tailscale-ip', (req, res) => { const options = { - socketPath: '/var/run/tailscale/tailscaled.sock', + socketPath: CONFIG.TAILSCALE_SOCKET, path: '/localapi/v0/status', method: 'GET', - headers: { 'Sec-Tailscale': 'localapi', 'Host': 'local-tailscaled.sock' }, + headers: { 'Sec-Tailscale': 'localapi', Host: 'local-tailscaled.sock' }, }; - const tsReq = http.request(options, (tsRes) => { + const tsReq = http.request(options, tsRes => { let data = ''; - tsRes.on('data', chunk => { data += chunk; }); + tsRes.on('data', chunk => { + data += chunk; + }); tsRes.on('end', () => { try { const status = JSON.parse(data); @@ -161,7 +53,7 @@ router.get('/tailscale-ip', (req, res) => { }); }); - tsReq.on('error', (e) => { + tsReq.on('error', e => { console.error('Error querying Tailscale:', e); res.status(500).json({ error: 'Failed to connect to Tailscale socket' }); }); @@ -173,7 +65,7 @@ router.get('/containers', async (req, res) => { try { const containers = await docker.listContainers({ all: true }); const containerInfo = await Promise.all( - containers.map(async (container) => { + containers.map(async container => { const inspect = await docker.getContainer(container.Id).inspect(); const ports = []; if (inspect.NetworkSettings?.Ports) { @@ -199,7 +91,7 @@ router.get('/containers', async (req, res) => { ports, networks: Object.keys(inspect.NetworkSettings.Networks || {}), }; - }) + }), ); res.json(containerInfo); } catch (error) { @@ -222,14 +114,16 @@ router.get('/containers/:id/logs', async (req, res) => { router.get('/storage', async (req, res) => { try { const now = Date.now(); - if (storageCache && (now - storageCacheTime) < STORAGE_CACHE_TTL) { + if (storageCache && now - storageCacheTime < STORAGE_CACHE_TTL) { return res.json(storageCache); } const { stdout: dfOutput } = await execFileAsync('df', ['-B1', '/hostfs'], { timeout: 5000 }); const dfParts = dfOutput.trim().split('\n')[1].split(/\s+/); const disk = { - total: parseInt(dfParts[1]), used: parseInt(dfParts[2]), - available: parseInt(dfParts[3]), percentUsed: parseFloat(dfParts[4]), + total: parseInt(dfParts[1]), + used: parseInt(dfParts[2]), + available: parseInt(dfParts[3]), + percentUsed: parseFloat(dfParts[4]), }; const dirs = [ { name: 'TV Downloads', path: '/hostdocker/downloads/tv' }, @@ -237,13 +131,17 @@ router.get('/storage', async (req, res) => { { name: 'Movies', path: '/hostdocker/movies' }, { name: 'Configs', path: '/hostdocker/configs' }, ]; - const breakdown = await Promise.all(dirs.map(async (d) => { - try { - const { stdout } = await execFileAsync('du', ['-sb', d.path], { timeout: 30000 }); - const size = parseInt(stdout.split('\t')[0], 10); - return { name: d.name, path: d.path, size: isNaN(size) ? 0 : size }; - } catch { return { name: d.name, path: d.path, size: 0 }; } - })); + const breakdown = await Promise.all( + dirs.map(async d => { + try { + const { stdout } = await execFileAsync('du', ['-sb', d.path], { timeout: 30000 }); + const size = parseInt(stdout.split('\t')[0], 10); + return { name: d.name, path: d.path, size: isNaN(size) ? 0 : size }; + } catch { + return { name: d.name, path: d.path, size: 0 }; + } + }), + ); const result = { disk, breakdown, timestamp: new Date().toISOString() }; storageCache = result; storageCacheTime = now; @@ -257,226 +155,69 @@ router.get('/storage', async (req, res) => { router.get('/docker/status', (req, res) => res.redirect('/api/containers')); router.get('/status', async (req, res) => { - const installerState = readInstallerState(); - const installerProgress = inferInstallerProgress(installerState); - const configuredLibraryServices = LIBRARY_SERVICE_NAMES.filter((name) => { - switch (name) { - case 'radarr': - return hasText(CONFIG.RADARR_API_KEY); - case 'sonarr': - return hasText(CONFIG.SONARR_API_KEY); - case 'lidarr': - return hasText(CONFIG.LIDARR_API_KEY); - default: - return false; - } - }); - const installerRequestedLibraryServices = LIBRARY_SERVICE_NAMES.filter((name) => installerState?.services?.[name] === true); - const expectedLibraryServices = installerRequestedLibraryServices.length > 0 - ? installerRequestedLibraryServices - : configuredLibraryServices; - const expectedServiceSet = new Set([ - ...expectedLibraryServices, - ...['qbittorrent', 'prowlarr', 'slskd'].filter((name) => installerState?.services?.[name] === true), - ]); - - const serviceDefinitions = [ + const services = {}; + const qbHost = CONFIG.QBITTORRENT_HOST; + // Missing credentials are a setup state; thrown probe errors are normalized to "down" below. + const checks = [ { name: 'radarr', url: CONFIG.RADARR_HOST, - configured: hasText(CONFIG.RADARR_API_KEY), - expectedBySetup: expectedServiceSet.has('radarr'), - requiredForSetup: expectedLibraryServices.includes('radarr'), - probe: async () => { - if (!hasText(CONFIG.RADARR_API_KEY)) return { status: 'unconfigured', reason: 'missing_api_key' }; + check: async () => { + if (!CONFIG.RADARR_API_KEY) return 'unconfigured'; await fetchWithTimeout(`${CONFIG.RADARR_HOST}/api/v3/system/status?apikey=${CONFIG.RADARR_API_KEY}`, 3000); - return { status: 'up', reason: 'ready', reachable: true, healthy: true }; + return 'up'; }, }, { name: 'sonarr', url: CONFIG.SONARR_HOST, - configured: hasText(CONFIG.SONARR_API_KEY), - expectedBySetup: expectedServiceSet.has('sonarr'), - requiredForSetup: expectedLibraryServices.includes('sonarr'), - probe: async () => { - if (!hasText(CONFIG.SONARR_API_KEY)) return { status: 'unconfigured', reason: 'missing_api_key' }; + check: async () => { + if (!CONFIG.SONARR_API_KEY) return 'unconfigured'; await fetchWithTimeout(`${CONFIG.SONARR_HOST}/api/v3/system/status?apikey=${CONFIG.SONARR_API_KEY}`, 3000); - return { status: 'up', reason: 'ready', reachable: true, healthy: true }; + return 'up'; }, }, { name: 'lidarr', url: CONFIG.LIDARR_HOST, - configured: hasText(CONFIG.LIDARR_API_KEY), - expectedBySetup: expectedServiceSet.has('lidarr'), - requiredForSetup: expectedLibraryServices.includes('lidarr'), - probe: async () => { - if (!hasText(CONFIG.LIDARR_API_KEY)) return { status: 'unconfigured', reason: 'missing_api_key' }; + check: async () => { + if (!CONFIG.LIDARR_API_KEY) return 'unconfigured'; await fetchWithTimeout(`${CONFIG.LIDARR_HOST}/api/v1/system/status?apikey=${CONFIG.LIDARR_API_KEY}`, 3000); - return { status: 'up', reason: 'ready', reachable: true, healthy: true }; - }, - }, - { - name: 'prowlarr', - url: CONFIG.PROWLARR_HOST, - configured: hasText(CONFIG.PROWLARR_API_KEY), - expectedBySetup: expectedServiceSet.has('prowlarr') || hasText(CONFIG.PROWLARR_API_KEY), - requiredForSetup: false, - probe: async () => { - if (!hasText(CONFIG.PROWLARR_API_KEY)) return { status: 'unconfigured', reason: 'missing_api_key' }; - await fetchWithTimeout(`${CONFIG.PROWLARR_HOST}/api/v1/system/status?apikey=${CONFIG.PROWLARR_API_KEY}`, 3000); - return { status: 'up', reason: 'ready', reachable: true, healthy: true }; + return 'up'; }, }, { name: 'slskd', url: CONFIG.SLSKD_HOST, - configured: hasText(CONFIG.SLSKD_API_KEY), - expectedBySetup: expectedServiceSet.has('slskd') || hasText(CONFIG.SLSKD_API_KEY), - requiredForSetup: false, - probe: async () => { - if (!hasText(CONFIG.SLSKD_API_KEY)) return { status: 'unconfigured', reason: 'missing_api_key' }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - try { - const resp = await fetch(`${CONFIG.SLSKD_HOST}/api/v0/application`, { - headers: { 'X-API-Key': CONFIG.SLSKD_API_KEY }, - signal: controller.signal, - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - return { status: 'up', reason: 'ready', reachable: true, healthy: true }; - } finally { - clearTimeout(timer); - } + check: async () => { + if (!CONFIG.SLSKD_API_KEY) return 'unconfigured'; + await fetchWithTimeout(`${CONFIG.SLSKD_HOST}/api/v0/application`, 3000, { + 'X-API-Key': CONFIG.SLSKD_API_KEY, + }); + return 'up'; }, }, { name: 'qbittorrent', - url: CONFIG.QBITTORRENT_HOST, - configured: hasQbittorrentCredentials(), - expectedBySetup: expectedServiceSet.has('qbittorrent') || hasQbittorrentCredentials(), - requiredForSetup: false, - probe: async () => { - if (!hasQbittorrentCredentials()) { - try { - const version = await probeQbittorrentVersion(3000); - return { - status: 'unconfigured', - reason: 'missing_credentials', - reachable: true, - version, - }; - } catch (error) { - return { - status: 'unconfigured', - reason: 'missing_credentials', - reachable: false, - error: expectedServiceSet.has('qbittorrent') ? error.message : undefined, - }; - } - } - - try { - const version = (await qbFetchText('/api/v2/app/version', { timeoutMs: 3000 })).trim(); - return { status: 'up', reason: 'ready', reachable: true, healthy: true, version }; - } catch (error) { - try { - const version = await probeQbittorrentVersion(3000); - return { - status: 'down', - reason: 'authentication_failed', - reachable: true, - healthy: false, - version, - error: error.message, - }; - } catch { - throw error; - } - } + url: qbHost, + check: async () => { + // Version stays unauthenticated, so it is the cheapest liveness check for qBittorrent. + await fetchWithTimeout(`${qbHost}/api/v2/app/version`, 3000, {}, { parseJson: false }); + return 'up'; }, }, ]; - - const services = Object.fromEntries( - await Promise.all(serviceDefinitions.map(async (definition) => ( - [definition.name, await resolveServiceStatus(definition)] - ))) + await Promise.allSettled( + checks.map(async ({ name, url, check }) => { + try { + const status = await check(); + services[name] = { status, url: serviceOrigin(url) }; + } catch (err) { + services[name] = { status: 'down', url: serviceOrigin(url), error: err.message }; + } + }), ); - - const summary = { - up: 0, - down: 0, - unconfigured: 0, - required: { up: 0, down: 0, unconfigured: 0 }, - optional: { up: 0, down: 0, unconfigured: 0 }, - }; - for (const service of Object.values(services)) { - incrementSummaryBucket(summary, service.status); - incrementSummaryBucket( - service.expectedBySetup || service.requiredForSetup ? summary.required : summary.optional, - service.status - ); - } - - const readyLibraryServices = expectedLibraryServices.filter((name) => services[name]?.status === 'up'); - const downLibraryServices = expectedLibraryServices.filter((name) => services[name]?.status === 'down'); - const unconfiguredLibraryServices = expectedLibraryServices.filter((name) => services[name]?.status === 'unconfigured'); - const setupComplete = expectedLibraryServices.length > 0 - && readyLibraryServices.length === expectedLibraryServices.length - && installerProgress.explicitFailed !== true - && installerProgress.explicitInProgress !== true - && installerProgress.explicitComplete !== false; - const setupRequired = !setupComplete; - const setupStatus = setupComplete - ? 'complete' - : installerProgress.explicitFailed === true || installerProgress.lastError - ? 'error' - : installerProgress.explicitInProgress === true - ? 'in_progress' - : expectedLibraryServices.length === 0 - ? 'unconfigured' - : downLibraryServices.length > 0 - ? 'degraded' - : unconfiguredLibraryServices.length > 0 - ? 'unconfigured' - : 'waiting_for_services'; - const hasIssues = Object.values(services).some((service) => { - if (service.status === 'down') return service.expectedBySetup || service.configured; - if (service.status === 'unconfigured') return service.expectedBySetup || service.requiredForSetup; - return false; - }) || installerProgress.explicitFailed === true; - - res.json({ - services, - summary, - setupRequired, - setupComplete, - setup: { - mode: installerState?.managed === true ? 'managed' : configuredLibraryServices.length > 0 ? 'manual' : 'unconfigured', - managed: installerState?.managed === true, - status: setupStatus, - required: setupRequired, - complete: setupComplete, - phase: installerProgress.phase, - installerState: { - complete: installerProgress.explicitComplete === true, - failed: installerProgress.explicitFailed === true, - inProgress: installerProgress.explicitInProgress === true, - lastError: installerProgress.lastError, - }, - library: { - expectedServices: expectedLibraryServices, - configuredServices: configuredLibraryServices, - readyServices: readyLibraryServices, - downServices: downLibraryServices, - unconfiguredServices: unconfiguredLibraryServices, - }, - }, - hasIssues, - timestamp: new Date().toISOString(), - }); + res.json({ services, timestamp: new Date().toISOString() }); }); export default router; diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index a0c52e3..fd18887 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -7,7 +7,6 @@ import libraryRoutes from './library.js'; import mediaRoutes from './media.js'; import slskdRoutes from './slskd.js'; import activityRoutes from './activity.js'; -import installerRoutes from './installer.js'; const router = Router(); @@ -20,6 +19,5 @@ router.use(libraryRoutes); router.use(mediaRoutes); router.use(slskdRoutes); router.use(activityRoutes); -router.use(installerRoutes); export default router; diff --git a/backend/src/routes/library.js b/backend/src/routes/library.js index 3d96eff..79a18f0 100644 --- a/backend/src/routes/library.js +++ b/backend/src/routes/library.js @@ -1,1593 +1,27 @@ import { Router } from 'express'; -import { CONFIG, TIMING, getDefaultRootFolder } from '../config.js'; -import { - fetchWithTimeout, pickImageUrl, pickArrImageUrl, - parseArrError, normalizeForMatch, -} from '../utils.js'; -import { - logActivity, updateLogEntry, addLogStep, - libraryCache, setLibraryCache, itunesPosterCache, - addPipelineItem, advancePipeline, registerInterval, -} from '../state.js'; -import { searchAndDownloadSlskd } from './slskd.js'; -import { prepareSonarrSearch } from './pipeline.js'; -import { execFileSync } from 'child_process'; -import { statSync } from 'fs'; +import { startLibraryCacheRefresh, refreshLibraryCache } from '../library/cacheRefresh.js'; +import cacheSearchRoutes from '../library/routes/cacheSearch.js'; +import tvRoutes from '../library/routes/tv.js'; +import movieRoutes from '../library/routes/movie.js'; +import musicRoutes from '../library/routes/music.js'; +import lookupRoutes from '../library/routes/lookup.js'; +import profileRoutes from '../library/routes/profiles.js'; +import addRoutes from '../library/routes/add.js'; +import deleteRoutes from '../library/routes/delete.js'; -const router = Router(); - -const { - RADARR_HOST, RADARR_API_KEY, - SONARR_HOST, SONARR_API_KEY, - LIDARR_HOST, LIDARR_API_KEY, - SLSKD_API_KEY, -} = CONFIG; -const LIBRARY_SEARCH_TYPES = Object.freeze({ - series: { responseKey: 'series', stateKey: 'series', service: 'sonarr' }, - movie: { responseKey: 'movies', stateKey: 'movies', service: 'radarr' }, - music: { responseKey: 'artists', stateKey: 'artists', service: 'lidarr' }, -}); - -function buildServiceErrorPayload(service, code, message, details, extra = {}) { - return { - error: message, - service, - code, - details, - retryable: code !== 'unconfigured', - ...extra, - }; -} - -function sendServiceError(res, service, statusCode, code, message, details, extra = {}) { - return res.status(statusCode).json(buildServiceErrorPayload(service, code, message, details, extra)); -} - -function getRequestedLibrarySearchTypes(type) { - if (type === 'all') return Object.keys(LIBRARY_SEARCH_TYPES); - return LIBRARY_SEARCH_TYPES[type] ? [type] : []; -} - -// ─── Library Cache Refresh ────────────────────────────────────────────────── - -let libraryCacheRefreshInterval = null; -let libraryCacheRefreshPromise = null; - -export async function refreshLibraryCache() { - if (libraryCacheRefreshPromise) return libraryCacheRefreshPromise; - - libraryCacheRefreshPromise = (async () => { - const nextCache = { - movies: libraryCache.movies, - series: libraryCache.series, - artists: libraryCache.artists, - albums: libraryCache.albums || [], - lastRefresh: libraryCache.lastRefresh || null, - serviceStates: { - series: SONARR_API_KEY ? { status: 'stale', error: null } : { status: 'unconfigured', error: null }, - movies: RADARR_API_KEY ? { status: 'stale', error: null } : { status: 'unconfigured', error: null }, - artists: LIDARR_API_KEY ? { status: 'stale', error: null } : { status: 'unconfigured', error: null }, - }, - }; - let refreshedSectionCount = 0; - const tasks = []; - - if (SONARR_API_KEY) { - tasks.push((async () => { - try { - const series = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`, 10000); - nextCache.series = series.map(s => ({ - id: s.id, tvdbId: s.tvdbId, title: s.title, sortTitle: s.sortTitle, - year: s.year, overview: s.overview, network: s.network, - genres: s.genres || [], ratings: s.ratings, - seasonCount: s.statistics?.seasonCount || s.seasonCount || 0, - totalEpisodeCount: s.statistics?.totalEpisodeCount || 0, - episodeFileCount: s.statistics?.episodeFileCount || 0, - sizeOnDisk: s.statistics?.sizeOnDisk || 0, - posterUrl: pickArrImageUrl(s.images, 'poster', 'sonarr'), - status: s.status, path: s.path, monitored: s.monitored, - })); - nextCache.serviceStates.series = { status: 'ready', error: null }; - refreshedSectionCount++; - } catch (err) { - nextCache.serviceStates.series = { status: 'error', error: err.message }; - console.error('Library cache - Sonarr:', err.message); - } - })()); - } - - if (RADARR_API_KEY) { - tasks.push((async () => { - try { - const movies = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`, 10000); - nextCache.movies = movies.map(m => ({ - id: m.id, tmdbId: m.tmdbId, title: m.title, sortTitle: m.sortTitle, - year: m.year, overview: m.overview, genres: m.genres || [], - ratings: m.ratings, runtime: m.runtime, - posterUrl: pickArrImageUrl(m.images, 'poster', 'radarr'), - hasFile: m.hasFile, monitored: m.monitored, status: m.status, - path: m.path, sizeOnDisk: m.sizeOnDisk || 0, - quality: m.movieFile?.quality?.quality?.name || null, - })); - nextCache.serviceStates.movies = { status: 'ready', error: null }; - refreshedSectionCount++; - } catch (err) { - nextCache.serviceStates.movies = { status: 'error', error: err.message }; - console.error('Library cache - Radarr:', err.message); - } - })()); - } - - if (LIDARR_API_KEY) { - tasks.push((async () => { - try { - const artists = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist?apikey=${LIDARR_API_KEY}`, 10000); - const artistList = artists.map(a => ({ - id: a.id, foreignArtistId: a.foreignArtistId, - artistName: a.artistName, sortName: a.sortName, - overview: a.overview, genres: a.genres || [], - posterUrl: pickImageUrl(a.images, 'poster') || pickImageUrl(a.images, 'cover') || null, - monitored: a.monitored, status: a.status, path: a.path, - albumCount: a.statistics?.albumCount || 0, - trackFileCount: a.statistics?.trackFileCount || 0, - trackCount: a.statistics?.trackCount || 0, - sizeOnDisk: a.statistics?.sizeOnDisk || 0, - })); - const noPoster = artistList.filter(a => !a.posterUrl); - if (noPoster.length > 0) { - await Promise.allSettled(noPoster.map(async (a) => { - try { - const albums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${a.id}&apikey=${LIDARR_API_KEY}`, 10000); - for (const alb of albums) { - const cover = pickImageUrl(alb.images, 'cover'); - if (cover) { a.posterUrl = cover; break; } - } - } catch {} - })); - } - // Lidarr artist records often lack art; borrow the first album cover so library cards stay populated. - nextCache.artists = artistList; - try { - const allAlbums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?apikey=${LIDARR_API_KEY}`, 15000); - const downloadedByArtist = {}; - allAlbums.forEach(a => { - if ((a.statistics?.trackFileCount || 0) > 0) - downloadedByArtist[a.artistId] = (downloadedByArtist[a.artistId] || 0) + 1; - }); - artistList.forEach(a => { a.downloadedAlbumCount = downloadedByArtist[a.id] || 0; }); - // Keep the lightweight library cache scoped to albums that already have files on disk. - nextCache.albums = allAlbums - .filter(a => (a.statistics?.trackFileCount || 0) > 0) - .map(a => ({ - id: a.id, title: a.title, artistId: a.artistId, - coverUrl: pickImageUrl(a.images, 'cover') || null, - })); - } catch (e) { - console.error('Library cache - Lidarr albums:', e.message); - } - nextCache.serviceStates.artists = { status: 'ready', error: null }; - refreshedSectionCount++; - } catch (err) { - nextCache.serviceStates.artists = { status: 'error', error: err.message }; - console.error('Library cache - Lidarr:', err.message); - } - })()); - } - - await Promise.allSettled(tasks); - if (refreshedSectionCount > 0) { - nextCache.lastRefresh = new Date().toISOString(); - } - setLibraryCache(nextCache); - return nextCache; - })().finally(() => { - libraryCacheRefreshPromise = null; - }); - - return libraryCacheRefreshPromise; -} - -refreshLibraryCache(); -registerInterval(setInterval(refreshLibraryCache, 60000)); - -// ─── Routes ───────────────────────────────────────────────────────────────── - -router.get('/library/refresh', async (req, res) => { - const cache = await refreshLibraryCache(); - res.json({ ok: true, lastRefresh: cache.lastRefresh, serviceStates: cache.serviceStates }); -}); - -router.get('/library/search', (req, res) => { - const q = (req.query.q || '').toLowerCase().trim(); - const type = req.query.type || 'all'; - const results = { series: [], movies: [], artists: [] }; - - if (type === 'all' || type === 'series') { - results.series = libraryCache.series - .filter(s => !q || s.title.toLowerCase().includes(q)) - .sort((a, b) => a.sortTitle.localeCompare(b.sortTitle)) - .slice(0, 50); - } - if (type === 'all' || type === 'movie') { - results.movies = libraryCache.movies - .filter(m => !q || m.title.toLowerCase().includes(q)) - .sort((a, b) => a.sortTitle.localeCompare(b.sortTitle)) - .slice(0, 50); - } - if (type === 'all' || type === 'music') { - results.artists = libraryCache.artists - .filter(a => !q || a.artistName.toLowerCase().includes(q)) - .sort((a, b) => (a.sortName || a.artistName).localeCompare(b.sortName || b.artistName)) - .slice(0, 50); - } - const requestedTypes = getRequestedLibrarySearchTypes(type); - const serviceErrors = {}; - for (const requestedType of requestedTypes) { - const mapping = LIBRARY_SEARCH_TYPES[requestedType]; - const state = libraryCache.serviceStates?.[mapping.stateKey]; - if (!state || !['error', 'unconfigured'].includes(state.status)) continue; - if ((results[mapping.responseKey] || []).length > 0) continue; - serviceErrors[mapping.responseKey] = { - service: mapping.service, - status: state.status, - error: state.error, - }; - } - res.json({ - ...results, - serviceStates: libraryCache.serviceStates, - serviceErrors, - hasServiceErrors: Object.keys(serviceErrors).length > 0, - requestedTypes, - lastRefresh: libraryCache.lastRefresh || null, - }); -}); - -router.get('/library/series/:id/episodes', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - try { - const sid = encodeURIComponent(req.params.id); - const [episodes, episodeFiles] = await Promise.all([ - fetchWithTimeout(`${SONARR_HOST}/api/v3/episode?seriesId=${sid}&apikey=${SONARR_API_KEY}`, 10000), - fetchWithTimeout(`${SONARR_HOST}/api/v3/episodefile?seriesId=${sid}&apikey=${SONARR_API_KEY}`, 10000), - ]); - const fileMap = {}; - (Array.isArray(episodeFiles) ? episodeFiles : []).forEach(f => { fileMap[f.id] = f; }); - const seasons = {}; - episodes.forEach(ep => { - const sn = ep.seasonNumber; - if (!seasons[sn]) seasons[sn] = []; - const ef = ep.episodeFileId ? fileMap[ep.episodeFileId] : null; - const mi = ef?.mediaInfo || {}; - seasons[sn].push({ - id: ep.id, episodeNumber: ep.episodeNumber, title: ep.title, - airDate: ep.airDateUtc, hasFile: ep.hasFile, monitored: ep.monitored, - overview: ep.overview, - runtime: ep.runtime || null, - quality: ef?.quality?.quality?.name || null, - size: ef?.size || 0, - filePath: ef?.path || null, - relativePath: ef?.relativePath || null, - videoCodec: mi.videoCodec || null, - videoFps: mi.videoFps || null, - resolution: mi.resolution || null, - audioCodec: mi.audioCodec || null, - audioChannels: mi.audioChannels || null, - audioLanguages: mi.audioLanguages || null, - runTime: mi.runTime || null, - subtitles: mi.subtitles || null, - dynamicRange: mi.videoDynamicRangeType || null, - imageUrl: null, - }); - }); - Object.values(seasons).forEach(eps => eps.sort((a, b) => a.episodeNumber - b.episodeNumber)); - - // Sonarr only exposes screenshot URLs on the per-episode resource, so enrich file-backed episodes in a second pass. - const epIdsNeedingImages = []; - for (const eps of Object.values(seasons)) { - for (const ep of eps) { - if (ep.hasFile && ep.id) epIdsNeedingImages.push(ep.id); - } - } - if (epIdsNeedingImages.length > 0) { - const imageMap = {}; - const BATCH_SIZE = 15; - for (let i = 0; i < epIdsNeedingImages.length; i += BATCH_SIZE) { - const batch = epIdsNeedingImages.slice(i, i + BATCH_SIZE); - const results = await Promise.allSettled( - batch.map(eid => fetchWithTimeout(SONARR_HOST + '/api/v3/episode/' + eid + '?apikey=' + SONARR_API_KEY, 5000)) - ); - results.forEach((result, j) => { - if (result.status === 'fulfilled' && result.value && result.value.images && result.value.images.length > 0) { - const sshot = result.value.images.find(img => img.coverType === 'screenshot'); - if (sshot && sshot.remoteUrl) { - imageMap[batch[j]] = '/api/poster?url=' + encodeURIComponent(sshot.remoteUrl); - } - } - }); - } - for (const eps of Object.values(seasons)) { - for (const ep of eps) { - if (imageMap[ep.id]) ep.imageUrl = imageMap[ep.id]; - } - } - } - - res.json({ seasons }); - } catch (err) { - console.error('Episode fetch error:', err.message); - res.status(502).json({ error: 'Failed to fetch episodes' }); - } -}); - -router.get('/library/movie/:id/file', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - try { - const files = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/moviefile?movieId=${encodeURIComponent(req.params.id)}&apikey=${RADARR_API_KEY}`, 8000 - ); - const f = Array.isArray(files) ? files[0] : files; - if (!f) return res.json(null); - const mi = f.mediaInfo || {}; - res.json({ - path: f.path || null, - relativePath: f.relativePath || null, - size: f.size || 0, - quality: f.quality?.quality?.name || null, - videoCodec: mi.videoCodec || null, - videoFps: mi.videoFps || null, - resolution: mi.resolution || null, - audioCodec: mi.audioCodec || null, - audioChannels: mi.audioChannels || null, - audioLanguages: mi.audioLanguages || null, - runTime: mi.runTime || null, - subtitles: mi.subtitles || null, - videoBitDepth: mi.videoBitDepth || null, - dynamicRange: mi.videoDynamicRangeType || null, - }); - } catch (err) { - console.error('Movie file info error:', err.message); - res.status(502).json({ error: err.message }); - } -}); - -router.get('/library/artists/:id/files', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const artist = await fetchWithTimeout( - `${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(req.params.id)}?apikey=${LIDARR_API_KEY}`, 10000 - ); - const artistPath = artist.path; - if (!artistPath) return res.json({ path: null, folders: [] }); - - const hostPath = artistPath.replace(/^\/data\//, '/hostdocker/'); - let folders = []; - try { - const lsOut = execFileSync('find', [hostPath, '-type', 'f'], { encoding: 'utf8', timeout: 10000 }); - const files = lsOut.trim().split('\n').filter(Boolean); - const grouped = {}; - for (const f of files) { - const rel = f.replace(hostPath + '/', ''); - const parts = rel.split('/'); - const folder = parts.length > 1 ? parts[0] : '.'; - if (!grouped[folder]) grouped[folder] = []; - grouped[folder].push({ - name: parts[parts.length - 1], - path: rel, - size: (() => { try { return statSync(f).size; } catch { return 0; } })(), - }); - } - folders = Object.entries(grouped).map(([name, files]) => ({ - name, - fileCount: files.length, - totalSize: files.reduce((s, f) => s + f.size, 0), - files, - })).sort((a, b) => a.name.localeCompare(b.name)); - } catch (e) { - console.error('File tree error:', e.message); - } - res.json({ path: artistPath, folders }); - } catch (err) { - console.error('Artist file tree error:', err.message); - res.status(502).json({ error: 'Failed to fetch file tree' }); - } -}); - -router.get('/library/artists/:id/albums', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const [albums, artist] = await Promise.all([ - fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${encodeURIComponent(req.params.id)}&apikey=${LIDARR_API_KEY}`, 10000), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(req.params.id)}?apikey=${LIDARR_API_KEY}`, 10000).catch(() => null), - ]); - const formatted = albums.map(a => ({ - id: a.id, title: a.title, releaseDate: a.releaseDate, - genres: a.genres || [], overview: a.overview, monitored: a.monitored, - albumType: a.albumType || 'Album', - coverUrl: pickImageUrl(a.images, 'cover'), - trackCount: a.statistics?.trackCount || 0, - trackFileCount: a.statistics?.trackFileCount || 0, - sizeOnDisk: a.statistics?.sizeOnDisk || 0, - percentOfTracks: a.statistics?.percentOfTracks || 0, - })); - formatted.sort((a, b) => new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)); - res.json({ - albums: formatted, - artist: artist ? { - id: artist.id, - foreignArtistId: artist.foreignArtistId, - artistName: artist.artistName, - metadataProfileId: artist.metadataProfileId, - } : null, - }); - } catch (err) { - console.error('Album fetch error:', err.message); - res.status(502).json({ error: 'Failed to fetch albums' }); - } -}); - -async function ensureExtendedMetadataProfile() { - const profiles = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`, 10000); - // Selective album adds depend on Lidarr seeing EPs and singles, not just the default album set. - const wantPrimary = new Set(['Album', 'EP', 'Single']); - const matching = profiles.find(p => { - const primAllowed = new Set((p.primaryAlbumTypes || []).filter(t => t.allowed).map(t => t.albumType?.name)); - return [...wantPrimary].every(n => primAllowed.has(n)); - }); - if (matching) return matching.id; - const base = profiles[0]; - if (!base) throw new Error('No Lidarr metadata profile to clone'); - const newProf = { - name: 'Standard Extended', - primaryAlbumTypes: (base.primaryAlbumTypes || []).map(t => ({ - ...t, - allowed: ['Album', 'EP', 'Single'].includes(t.albumType?.name), - })), - secondaryAlbumTypes: (base.secondaryAlbumTypes || []).map(t => ({ - ...t, - allowed: t.albumType?.name === 'Studio' ? true : !!t.allowed, - })), - releaseStatuses: (base.releaseStatuses || []).map(t => ({ - ...t, - allowed: t.releaseStatus?.name === 'Official' ? true : !!t.allowed, - })), - }; - const resp = await fetch(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newProf), - }); - if (!resp.ok) { - const txt = await resp.text().catch(() => ''); - throw new Error(`Create metadata profile failed: HTTP ${resp.status} ${txt.substring(0, 120)}`); - } - const created = await resp.json(); - return created.id; -} - -router.post('/library/artists/:id/albums/add', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const { selectedAlbumTitles } = req.body || {}; - if (!Array.isArray(selectedAlbumTitles) || selectedAlbumTitles.length === 0) { - return res.status(400).json({ error: 'selectedAlbumTitles required' }); - } - const artistId = req.params.id; - let logId = null; - - try { - const artist = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${encodeURIComponent(artistId)}?apikey=${LIDARR_API_KEY}`, 10000); - if (!artist?.id) return res.status(404).json({ error: 'Artist not found' }); - - logId = logActivity('add', `Adding ${selectedAlbumTitles.length} extra album(s) to "${artist.artistName}"...`, - { artistId: artist.id, count: selectedAlbumTitles.length }, 'pending', - { service: 'lidarr', artistName: artist.artistName }); - - let profileBumped = false; - try { - const extId = await ensureExtendedMetadataProfile(); - if (artist.metadataProfileId !== extId) { - artist.metadataProfileId = extId; - const putResp = await fetch(`${LIDARR_HOST}/api/v1/artist/${artist.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(artist), - }); - if (putResp.ok) { profileBumped = true; addLogStep(logId, 'Switched artist to extended metadata profile (Album+EP+Single)', 'success'); } - else addLogStep(logId, `Warning: could not switch metadata profile (HTTP ${putResp.status})`, 'warning'); - } - } catch (e) { - addLogStep(logId, `Metadata profile setup failed: ${e.message}`, 'warning'); - } - - if (profileBumped) { - try { - const r = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'RefreshArtist', artistId: artist.id }), - }); - if (!r.ok) addLogStep(logId, `Warning: RefreshArtist HTTP ${r.status}`, 'warning'); - else addLogStep(logId, 'Refreshing artist metadata...', 'pending'); - } catch (e) { - addLogStep(logId, `RefreshArtist failed: ${e.message}`, 'warning'); - } - } - - const normalize = (s) => (s || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') - .replace(/\s*\(.*?\)\s*/g, ' ').replace(/\s*\[.*?\]\s*/g, ' ') - .replace(/ - (single|ep)$/i, '').replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ').trim(); - - const targetNorms = selectedAlbumTitles.map(t => ({ original: t, norm: normalize(t) })); - - let albums = []; - const pollCount = profileBumped ? 15 : 1; - for (let attempt = 0; attempt < pollCount; attempt++) { - try { - albums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${artist.id}&apikey=${LIDARR_API_KEY}`, 10000); - } catch (e) { } - const matchedAll = targetNorms.every(sel => - albums.some(a => { - const an = normalize(a.title); - if (an === sel.norm) return true; - if (sel.norm.length < 3 || an.length < 3) return false; - const shorter = sel.norm.length <= an.length ? sel.norm : an; - const longer = sel.norm.length > an.length ? sel.norm : an; - if (shorter.length / longer.length < 0.6) return false; - return longer.includes(shorter); - }) - ); - if (matchedAll) break; - if (attempt < pollCount - 1) await new Promise(r => setTimeout(r, 2000)); - } - addLogStep(logId, `Lidarr now reports ${albums.length} total album(s) for artist`, 'pending'); - - const matched = []; - const matchedOriginals = new Set(); - const albumsWithMatches = albums.map(album => { - const albumNorm = normalize(album.title || ''); - const matchEntry = targetNorms.find(sel => sel.norm === albumNorm) || - targetNorms.find(sel => { - if (sel.norm.length < 3 || albumNorm.length < 3) return false; - const shorter = sel.norm.length <= albumNorm.length ? sel.norm : albumNorm; - const longer = sel.norm.length > albumNorm.length ? sel.norm : albumNorm; - if (shorter.length / longer.length < 0.6) return false; - return longer.includes(shorter); - }); - return { album, matchEntry }; - }).filter(({ matchEntry }) => matchEntry != null); - - const albumsToSearch = []; - await Promise.all(albumsWithMatches.map(async ({ album, matchEntry }) => { - matchedOriginals.add(matchEntry.original); - if (!album.monitored) { - album.monitored = true; - try { - await fetch(`${LIDARR_HOST}/api/v1/album/${album.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(album), - }); - } catch (e) { - addLogStep(logId, `Failed to monitor "${album.title}": ${e.message}`, 'error'); - return; - } - } - matched.push(album.title); - albumsToSearch.push({ id: album.id, title: album.title }); - })); - - const unmatchedAlbumTitles = selectedAlbumTitles.filter(t => !matchedOriginals.has(t)); - if (matched.length > 0) addLogStep(logId, `Monitoring ${matched.length} album(s): ${matched.join(', ')}`, 'success'); - if (unmatchedAlbumTitles.length > 0) addLogStep(logId, `${unmatchedAlbumTitles.length} not in Lidarr (will try Soulseek): ${unmatchedAlbumTitles.join(', ')}`, 'warning'); - - if (albumsToSearch.length > 0) { - try { - const r = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'AlbumSearch', albumIds: albumsToSearch.map(a => a.id) }), - }); - if (!r.ok) { - const txt = await r.text().catch(() => ''); - addLogStep(logId, `Lidarr search HTTP ${r.status} ${txt.substring(0, 100)}`, 'error'); - } else { - addLogStep(logId, `Lidarr search triggered for ${albumsToSearch.length} album(s)`, 'success'); - } - } catch (e) { - addLogStep(logId, `Lidarr search failed: ${e.message}`, 'error'); - } - } - - if (SLSKD_API_KEY) { - const searchAlbums = [ - ...albumsToSearch, - ...unmatchedAlbumTitles.map(t => ({ id: null, title: t })), - ]; - const resolvedArtistName = artist.artistName; - (async () => { - addLogStep(logId, `Starting Soulseek search for ${searchAlbums.length} album(s)...`, 'pending'); - let queued = 0; let failed = 0; - for (const album of searchAlbums) { - try { - const dl = await searchAndDownloadSlskd(resolvedArtistName, album.title, logId, artist.id, album.id); - if (dl.success) queued++; else failed++; - } catch { failed++; } - await new Promise(r => setTimeout(r, 1500)); - } - if (queued > 0) { - updateLogEntry(logId, { status: 'success', message: `"${resolvedArtistName}" — ${queued} extra album(s) downloading` }); - } else { - addLogStep(logId, `Soulseek queued nothing (failed: ${failed})`, 'warning'); - } - })().catch(err => { - addLogStep(logId, `Soulseek background error: ${err.message}`, 'error'); - }); - } - - refreshLibraryCache(); - res.json({ - success: true, - monitored: albumsToSearch.length, - unmatched: unmatchedAlbumTitles, - profileBumped, - }); - } catch (err) { - if (logId) updateLogEntry(logId, { status: 'error', message: `Add extra albums failed: ${err.message}` }); - console.error('Add extra albums error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// APPENDED ROUTES — Lookup, Profiles, Add, Delete -// ═══════════════════════════════════════════════════════════════════════════════ - -// ─── Lookup ───────────────────────────────────────────────────────────────── - -router.get('/lookup/movie', async (req, res) => { - if (!RADARR_API_KEY) { - return sendServiceError(res, 'radarr', 503, 'unconfigured', 'Radarr not configured', null); - } - const term = req.query.term; - if (!term) return res.status(400).json({ error: 'Missing term' }); - try { - const results = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/movie/lookup?term=${encodeURIComponent(term)}&apikey=${RADARR_API_KEY}`, 10000 - ); - res.json(results.slice(0, 20).map(m => ({ - tmdbId: m.tmdbId, imdbId: m.imdbId, title: m.title, year: m.year, - overview: m.overview, genres: m.genres || [], ratings: m.ratings, - runtime: m.runtime, studio: m.studio, - posterUrl: pickImageUrl(m.images, 'poster'), - inLibrary: libraryCache.movies.some(lm => lm.tmdbId === m.tmdbId), - }))); - } catch (err) { - console.error('Movie lookup error:', err.message); - return sendServiceError(res, 'radarr', 502, 'lookup_failed', 'Radarr lookup failed', err.message); - } -}); - -router.get('/lookup/series', async (req, res) => { - if (!SONARR_API_KEY) { - return sendServiceError(res, 'sonarr', 503, 'unconfigured', 'Sonarr not configured', null); - } - const term = req.query.term; - if (!term) return res.status(400).json({ error: 'Missing term' }); - try { - const results = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/series/lookup?term=${encodeURIComponent(term)}&apikey=${SONARR_API_KEY}`, 10000 - ); - res.json(results.slice(0, 20).map(s => ({ - tvdbId: s.tvdbId, title: s.title, year: s.year, - overview: s.overview, genres: s.genres || [], ratings: s.ratings, - network: s.network, seasonCount: s.seasonCount, - posterUrl: pickImageUrl(s.images, 'poster'), - inLibrary: libraryCache.series.some(ls => ls.tvdbId === s.tvdbId), - seasons: (s.seasons || []).map(sn => ({ - seasonNumber: sn.seasonNumber, - monitored: sn.monitored, - episodeCount: sn.statistics?.totalEpisodeCount || 0, - })), - }))); - } catch (err) { - console.error('Series lookup error:', err.message); - return sendServiceError(res, 'sonarr', 502, 'lookup_failed', 'Sonarr lookup failed', err.message); - } -}); - -router.get('/lookup/music', async (req, res) => { - if (!LIDARR_API_KEY) { - return sendServiceError(res, 'lidarr', 503, 'unconfigured', 'Lidarr not configured', null, { - artists: [], - albums: [], - singles: [], - topCategory: 'artists', - }); - } - const term = req.query.term; - if (!term) return res.status(400).json({ error: 'Missing term' }); - - const simScore = (query, target) => { - const q = query.toLowerCase().trim(); - const t = target.toLowerCase().trim(); - if (q === t) return 1.0; - if (t.startsWith(q) || q.startsWith(t)) return 0.9; - if (t.includes(q) || q.includes(t)) return 0.8; - const qw = new Set(q.split(/\s+/)); - const tw = new Set(t.split(/\s+/)); - const common = [...qw].filter(w => tw.has(w)).length; - const union = new Set([...qw, ...tw]).size; - return union > 0 ? common / union : 0; - }; - - try { - const [rawArtists, rawAlbums] = await Promise.all([ - fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/lookup?term=${encodeURIComponent(term)}&apikey=${LIDARR_API_KEY}`, 10000), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/album/lookup?term=${encodeURIComponent(term)}&apikey=${LIDARR_API_KEY}`, 10000).catch(() => []), - ]); - - const artistResults = (rawArtists || []).slice(0, 15).map(a => ({ - foreignArtistId: a.foreignArtistId, artistName: a.artistName, - overview: a.overview, genres: a.genres || [], - disambiguation: a.disambiguation, artistType: a.artistType, - posterUrl: pickImageUrl(a.images, 'poster') || pickImageUrl(a.images, 'cover') || null, - inLibrary: libraryCache.artists.some(la => la.foreignArtistId === a.foreignArtistId), - score: simScore(term, a.artistName), - })); - - const albumResults = []; - const singleResults = []; - for (const a of (rawAlbums || []).slice(0, 30)) { - const artist = a.artist || {}; - const isInLibrary = libraryCache.artists.some(la => la.foreignArtistId === artist.foreignArtistId); - const item = { - foreignAlbumId: a.foreignAlbumId, - title: a.title, disambiguation: a.disambiguation || '', - releaseDate: a.releaseDate, - albumType: a.albumType || 'Album', - genres: a.genres || [], - artistName: artist.artistName || '', - foreignArtistId: artist.foreignArtistId || '', - posterUrl: pickImageUrl(a.images, 'cover') || null, - inLibrary: isInLibrary, - score: simScore(term, a.title), - }; - if (a.albumType === 'Single' || a.albumType === 'EP') singleResults.push(item); - else albumResults.push(item); - } - - const noPoster = artistResults.filter(r => !r.posterUrl).slice(0, 5); - for (const artist of noPoster) { - const hit = itunesPosterCache.get(artist.artistName); - if (hit && (Date.now() - hit.ts < 3600000)) { if (hit.url) artist.posterUrl = hit.url; continue; } - try { - const itunes = await fetchWithTimeout( - 'https://itunes.apple.com/search?term=' + encodeURIComponent(artist.artistName) + '&entity=album&limit=1', 5000 - ); - const url = (itunes.results && itunes.results[0] && itunes.results[0].artworkUrl100) - ? itunes.results[0].artworkUrl100.replace('100x100', '600x600') : null; - itunesPosterCache.set(artist.artistName, { url, ts: Date.now() }); - if (url) artist.posterUrl = url; - } catch {} - await new Promise(r => setTimeout(r, 150)); - } - - const topArtistScore = Math.max(0, ...artistResults.map(r => r.score)); - const topAlbumScore = Math.max(0, ...albumResults.map(r => r.score)); - const topSingleScore = Math.max(0, ...singleResults.map(r => r.score)); - const maxScore = Math.max(topArtistScore, topAlbumScore, topSingleScore); - let topCategory = 'artists'; - if (topAlbumScore === maxScore && topAlbumScore > topArtistScore) topCategory = 'albums'; - else if (topSingleScore === maxScore && topSingleScore > topArtistScore) topCategory = 'singles'; - - artistResults.sort((a, b) => b.score - a.score); - albumResults.sort((a, b) => b.score - a.score); - singleResults.sort((a, b) => b.score - a.score); - - res.json({ artists: artistResults, albums: albumResults, singles: singleResults, topCategory }); - } catch (err) { - console.error('Music lookup error:', err.message); - return sendServiceError(res, 'lidarr', 502, 'lookup_failed', 'Lidarr lookup failed', err.message, { - artists: [], - albums: [], - singles: [], - topCategory: 'artists', - }); - } -}); - -router.get('/lookup/music/albums', async (req, res) => { - const { artistName, foreignArtistId } = req.query; - if (!artistName) return res.status(400).json({ error: 'Missing artistName' }); - const normalize = s => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[.\-']/g, '').replace(/\s+/g, ' ').trim(); - const dedup = s => normalize(s.replace(/ - (Single|EP)$/i, '').replace(/\s*\((?:apple music edition|deluxe|deluxe edition|remastered|expanded|bonus track).*?\)$/i, '')); - const nameNorm = normalize(artistName); - - try { - const itunesArtistSearch = fetchWithTimeout( - `https://itunes.apple.com/search?term=${encodeURIComponent(artistName)}&entity=musicArtist&limit=5`, 10000 - ).catch(() => ({ results: [] })); +export { refreshLibraryCache }; - const mbPromise = foreignArtistId - ? fetchWithTimeout( - `https://musicbrainz.org/ws/2/release-group?artist=${foreignArtistId}&fmt=json&limit=100`, - 10000, - { 'User-Agent': 'vibarr/1.0 (music lookup)' } - ).catch(() => ({ 'release-groups': [] })) - : Promise.resolve({ 'release-groups': [] }); +startLibraryCacheRefresh(); - const [itunesArtists, mbData] = await Promise.all([itunesArtistSearch, mbPromise]); - - const itunesArtist = (itunesArtists.results || []).find(a => - normalize(a.artistName) === nameNorm - ) || (itunesArtists.results || [])[0]; - - let itunesAlbums = []; - if (itunesArtist?.artistId) { - try { - const lookupData = await fetchWithTimeout( - `https://itunes.apple.com/lookup?id=${itunesArtist.artistId}&entity=album&limit=200`, 15000 - ); - itunesAlbums = (lookupData.results || []).filter(r => r.wrapperType === 'collection'); - } catch {} - } - - let searchAlbums = []; - try { - const searchData = await fetchWithTimeout( - `https://itunes.apple.com/search?term=${encodeURIComponent(artistName)}&entity=album&limit=200`, 10000 - ); - searchAlbums = (searchData.results || []).filter(a => normalize(a.artistName) === nameNorm); - } catch {} - - const seenIds = new Set(); - const allItunes = []; - for (const a of [...itunesAlbums, ...searchAlbums]) { - if (!seenIds.has(a.collectionId)) { - seenIds.add(a.collectionId); - const titleLower = a.collectionName.toLowerCase(); - let albumType = 'Album'; - if (titleLower.includes('- single')) albumType = 'Single'; - else if (titleLower.includes('- ep') || titleLower.endsWith(' ep')) albumType = 'EP'; - allItunes.push({ - id: 'itunes-' + a.collectionId, - title: a.collectionName, - releaseDate: a.releaseDate || null, - trackCount: a.trackCount || 0, - albumType, - coverUrl: a.artworkUrl100 ? a.artworkUrl100.replace('100x100', '600x600') : null, - source: 'itunes', - }); - } - } - - const mbGroups = (mbData['release-groups'] || []).map(g => { - const ptype = (g['primary-type'] || '').toLowerCase(); - const stypes = (g['secondary-types'] || []).map(s => s.toLowerCase()); - let albumType = 'Album'; - if (ptype === 'single' || stypes.includes('single')) albumType = 'Single'; - else if (ptype === 'ep' || stypes.includes('ep')) albumType = 'EP'; - return { - id: 'mb-' + g.id, - title: g.title, - releaseDate: g['first-release-date'] ? g['first-release-date'] + 'T00:00:00Z' : null, - trackCount: 0, - albumType, - coverUrl: `https://coverartarchive.org/release-group/${g.id}/front-250`, - source: 'musicbrainz', - }; - }); - - const seenTitles = new Set(allItunes.map(a => dedup(a.title))); - const merged = [...allItunes]; - for (const mb of mbGroups) { - const mbTitleNorm = dedup(mb.title); - if (!seenTitles.has(mbTitleNorm)) { - seenTitles.add(mbTitleNorm); - merged.push(mb); - } - } - - const typeOrder = { Album: 0, EP: 1, Single: 2 }; - merged.sort((a, b) => { - const ta = typeOrder[a.albumType] ?? 1; - const tb = typeOrder[b.albumType] ?? 1; - if (ta !== tb) return ta - tb; - return (b.releaseDate || '').localeCompare(a.releaseDate || ''); - }); - - res.json(merged); - } catch (err) { - console.error('Album lookup error:', err.message); - res.status(502).json({ error: 'Failed to fetch albums' }); - } -}); - -// ─── Profiles ─────────────────────────────────────────────────────────────── - -router.get('/profiles/movie', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - try { - const [profiles, rootFolders] = await Promise.all([ - fetchWithTimeout(`${RADARR_HOST}/api/v3/qualityprofile?apikey=${RADARR_API_KEY}`), - fetchWithTimeout(`${RADARR_HOST}/api/v3/rootfolder?apikey=${RADARR_API_KEY}`), - ]); - res.json({ - qualityProfiles: profiles.map(p => ({ id: p.id, name: p.name })), - rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), - minimumAvailabilities: [ - { value: 'announced', label: 'Announced' }, - { value: 'inCinemas', label: 'In Cinemas' }, - { value: 'released', label: 'Released' }, - ], - }); - } catch (err) { - console.error('Movie profiles error:', err.message); - res.status(502).json({ error: 'Failed to fetch profiles' }); - } -}); - -router.get('/profiles/series', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - try { - const [profiles, rootFolders] = await Promise.all([ - fetchWithTimeout(`${SONARR_HOST}/api/v3/qualityprofile?apikey=${SONARR_API_KEY}`), - fetchWithTimeout(`${SONARR_HOST}/api/v3/rootfolder?apikey=${SONARR_API_KEY}`), - ]); - res.json({ - qualityProfiles: profiles.map(p => ({ id: p.id, name: p.name })), - rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), - seriesTypes: [ - { value: 'standard', label: 'Standard' }, - { value: 'daily', label: 'Daily' }, - { value: 'anime', label: 'Anime' }, - ], - monitorOptions: [ - { value: 'all', label: 'All Episodes' }, - { value: 'future', label: 'Future Episodes' }, - { value: 'missing', label: 'Missing Episodes' }, - { value: 'existing', label: 'Existing Episodes' }, - { value: 'pilot', label: 'Pilot Only' }, - { value: 'firstSeason', label: 'First Season' }, - { value: 'lastSeason', label: 'Last Season' }, - { value: 'none', label: 'None' }, - ], - }); - } catch (err) { - console.error('Series profiles error:', err.message); - res.status(502).json({ error: 'Failed to fetch profiles' }); - } -}); - -router.get('/profiles/music', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const [qualityProfiles, metadataProfiles, rootFolders] = await Promise.all([ - fetchWithTimeout(`${LIDARR_HOST}/api/v1/qualityprofile?apikey=${LIDARR_API_KEY}`), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/metadataprofile?apikey=${LIDARR_API_KEY}`), - fetchWithTimeout(`${LIDARR_HOST}/api/v1/rootfolder?apikey=${LIDARR_API_KEY}`), - ]); - res.json({ - qualityProfiles: qualityProfiles.map(p => ({ id: p.id, name: p.name })), - metadataProfiles: metadataProfiles.map(p => ({ id: p.id, name: p.name })), - rootFolders: rootFolders.map(f => ({ id: f.id, path: f.path, freeSpace: f.freeSpace })), - monitorOptions: [ - { value: 'all', label: 'All Albums' }, - { value: 'future', label: 'Future Albums' }, - { value: 'missing', label: 'Missing Albums' }, - { value: 'existing', label: 'Existing Albums' }, - { value: 'first', label: 'First Album' }, - { value: 'latest', label: 'Latest Album' }, - { value: 'none', label: 'None' }, - ], - }); - } catch (err) { - console.error('Music profiles error:', err.message); - res.status(502).json({ error: 'Failed to fetch profiles' }); - } -}); - -// ─── Add Media ────────────────────────────────────────────────────────────── - -router.post('/add/movie', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - const { tmdbId, qualityProfileId, rootFolderPath, minimumAvailability, monitored, searchForMovie } = req.body; - if (!tmdbId) return res.status(400).json({ error: 'Missing tmdbId' }); - const logId = logActivity('add', `Adding movie (tmdb:${tmdbId}) to Radarr...`, { tmdbId }, 'pending', { service: 'radarr', tmdbId }); - try { - const lookup = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/movie/lookup/tmdb?tmdbId=${tmdbId}&apikey=${RADARR_API_KEY}` - ); - const movieData = Array.isArray(lookup) ? lookup[0] : lookup; - if (!movieData) throw new Error('Movie not found'); - - const resp = await fetch(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...movieData, - qualityProfileId: qualityProfileId || 1, - rootFolderPath: rootFolderPath || getDefaultRootFolder('radarr') || '/movies', - minimumAvailability: minimumAvailability || 'released', - monitored: monitored !== false, - addOptions: { searchForMovie: false }, - }), - }); - - let result; - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - const errMsg = err[0]?.errorMessage || err.message || `HTTP ${resp.status}`; - if (errMsg.toLowerCase().includes('already been added') || errMsg.toLowerCase().includes('already exists')) { - const allMovies = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie?apikey=${RADARR_API_KEY}`); - result = allMovies.find(m => m.tmdbId === Number(tmdbId)); - if (!result?.id) throw new Error(errMsg); - if (!result.monitored || (qualityProfileId && result.qualityProfileId !== qualityProfileId)) { - await fetch(`${RADARR_HOST}/api/v3/movie/${result.id}?apikey=${RADARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...result, monitored: true, qualityProfileId: qualityProfileId || result.qualityProfileId }), - }).catch(() => {}); - } - updateLogEntry(logId, { status: 'info', message: `"${result.title}" already in Radarr — re-enabled monitoring and triggering search`, context: { service: 'radarr', title: result.title, movieId: result.id } }); - } else { - throw new Error(errMsg); - } - } else { - result = await resp.json(); - updateLogEntry(logId, { status: 'success', message: `Added "${result.title}" to Radarr, searching for downloads`, context: { service: 'radarr', title: result.title, movieId: result.id } }); - refreshLibraryCache(); - } - - const posterUrl = pickArrImageUrl(movieData.images || [], 'poster', 'radarr'); - const pipelineKey = `radarr-${result.id}-${Date.now()}`; - addPipelineItem(pipelineKey, { service: 'radarr', title: result.title, subtitle: 'Movie', posterUrl, logId, movieId: result.id, retryId: result.id }); - advancePipeline(pipelineKey, 'searching'); - if (searchForMovie !== false) { - fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'MoviesSearch', movieIds: [result.id] }), - }).then(r => r.json()).then(cmd => { - if (cmd?.id) watchRadarrSearch(pipelineKey, cmd.id, result.id, logId, result.title).catch(e => console.error('watchRadarrSearch add:', e.message)); - }).catch(() => {}); - } - - res.json({ success: true, id: result.id, title: result.title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to add movie: ${err.message}` }); - console.error('Add movie error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -// ─── Helpers for watch search ────────────────────────────────────────────── - -async function watchRadarrSearch(pipelineKey, commandId, movieId, logId, movieTitle) { - const RADARR_HOST = CONFIG.RADARR_HOST; - const RADARR_API_KEY = CONFIG.RADARR_API_KEY; - // Arr search commands can complete without grabbing anything; watchers confirm the downstream result first. - try { - let completed = false; - for (let i = 0; i < 30; i++) { - await new Promise(r => setTimeout(r, 3000)); - try { - const cmd = await fetchWithTimeout(`${RADARR_HOST}/api/v3/command/${commandId}?apikey=${RADARR_API_KEY}`, 8000); - if (cmd.status === 'completed') { - completed = true; - const grabUrl = `${RADARR_HOST}/api/v3/release?movieId=${movieId}&apikey=${RADARR_API_KEY}`; - let grabbed = 0; - try { - const releases = await fetchWithTimeout(grabUrl, 8000); - grabbed = (Array.isArray(releases) ? releases : []).filter(r => r.grabbed).length; - } catch {} - if (grabbed > 0) { - advancePipeline(pipelineKey, 'grabbed', { progress: null }); - if (logId) addLogStep(logId, `Grabbed ${grabbed} release(s) for "${movieTitle}"`, 'success'); - } else { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - if (logId) addLogStep(logId, 'Search completed — no releases grabbed (may appear in queue shortly)', 'pending'); - } - break; - } else if (cmd.status === 'failed') { - if (logId) addLogStep(logId, `Search command failed: ${cmd.errorMessage || 'unknown'}`, 'error'); - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - break; - } - } catch {} - } - if (!completed) { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - } - } catch (err) { - console.error('watchRadarrSearch error:', err.message); - } -} - -async function watchSonarrSearch(pipelineKey, commandId, seriesId, seasonNumber, logId, seriesTitle) { - const SONARR_HOST = CONFIG.SONARR_HOST; - const SONARR_API_KEY = CONFIG.SONARR_API_KEY; - try { - let completed = false; - for (let i = 0; i < 30; i++) { - await new Promise(r => setTimeout(r, 3000)); - try { - const cmd = await fetchWithTimeout(`${SONARR_HOST}/api/v3/command/${commandId}?apikey=${SONARR_API_KEY}`, 8000); - if (cmd.status === 'completed') { - completed = true; - const histUrl = `${SONARR_HOST}/api/v3/history/series?seriesId=${seriesId}&pageSize=50&apikey=${SONARR_API_KEY}`; - let grabbed = 0; - try { - const history = await fetchWithTimeout(histUrl, 8000); - const records = history.records || history || []; - const since = Date.now() - 120000; - grabbed = (Array.isArray(records) ? records : []).filter(r => r.eventType === 'grabbed' && new Date(r.date).getTime() > since).length; - } catch {} - if (grabbed > 0) { - advancePipeline(pipelineKey, 'grabbed', { progress: null }); - if (logId) addLogStep(logId, `Grabbed ${grabbed} release(s) for "${seriesTitle}"`, 'success'); - } else { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - if (logId) addLogStep(logId, 'Search completed — no releases grabbed', 'pending'); - } - break; - } else if (cmd.status === 'failed') { - if (logId) addLogStep(logId, `Search command failed: ${cmd.errorMessage || 'unknown'}`, 'error'); - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - break; - } - } catch {} - } - if (!completed) { - advancePipeline(pipelineKey, 'searching', { canRetry: true }); - } - } catch (err) { - console.error('watchSonarrSearch error:', err.message); - } -} - -router.post('/add/series', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - const { tvdbId, qualityProfileId, rootFolderPath, seriesType, monitored, monitorOption, searchForMissingEpisodes, selectedSeasons } = req.body; - if (!tvdbId) return res.status(400).json({ error: 'Missing tvdbId' }); - const logId = logActivity('add', `Adding series (tvdb:${tvdbId}) to Sonarr...`, { tvdbId }, 'pending', { service: 'sonarr', tvdbId }); - try { - const lookupResults = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/series/lookup?term=tvdb:${tvdbId}&apikey=${SONARR_API_KEY}` - ); - const seriesData = Array.isArray(lookupResults) ? lookupResults[0] : lookupResults; - if (!seriesData) throw new Error('Series not found'); - - if (selectedSeasons && seriesData.seasons) { - seriesData.seasons = seriesData.seasons.map(s => ({ - ...s, - monitored: selectedSeasons.includes(s.seasonNumber), - })); - } - - const resp = await fetch(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...seriesData, - qualityProfileId: qualityProfileId || 1, - rootFolderPath: rootFolderPath || getDefaultRootFolder('sonarr') || '/tv', - seriesType: seriesType || 'standard', - monitored: monitored !== false, - seasonFolder: true, - addOptions: { - monitor: selectedSeasons ? 'none' : (monitorOption || 'all'), - searchForMissingEpisodes: searchForMissingEpisodes !== false, - searchForCutoffUnmetEpisodes: false, - }, - }), - }); - - let result; - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - const errMsg = err[0]?.errorMessage || err.message || `HTTP ${resp.status}`; - if (errMsg.toLowerCase().includes('already been added') || errMsg.toLowerCase().includes('already exists')) { - const allSeries = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series?apikey=${SONARR_API_KEY}`); - result = allSeries.find(s => s.tvdbId === Number(tvdbId)); - if (!result?.id) throw new Error(errMsg); - const updatedSeries = { - ...result, - monitored: true, - seasons: result.seasons?.map(s => ({ - ...s, - monitored: selectedSeasons ? selectedSeasons.includes(s.seasonNumber) : s.monitored, - })) || [], - }; - await fetch(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedSeries), - }).catch(() => {}); - const seasonLabel = selectedSeasons ? ` (seasons ${selectedSeasons.join(', ')})` : ''; - updateLogEntry(logId, { status: 'info', message: `"${result.title}"${seasonLabel} already in Sonarr — re-enabled monitoring and triggering search`, context: { service: 'sonarr', title: result.title, seriesId: result.id, seasons: selectedSeasons } }); - } else { - throw new Error(errMsg); - } - } else { - result = await resp.json(); - const seasonLabel = selectedSeasons ? ` (seasons ${selectedSeasons.join(', ')})` : ''; - updateLogEntry(logId, { status: 'success', message: `Added "${result.title}"${seasonLabel} to Sonarr, searching for downloads`, context: { service: 'sonarr', title: result.title, seriesId: result.id, seasons: selectedSeasons } }); - refreshLibraryCache(); - } - - const seriesPoster = pickArrImageUrl(seriesData.images || [], 'poster', 'sonarr'); - if (selectedSeasons?.length) { - try { - const currentSeries = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`); - const selectedSeasonSet = new Set(selectedSeasons.map(Number)); - const updatedSeries = { - ...currentSeries, - monitored: true, - seasons: (currentSeries.seasons || []).map((season) => ({ - ...season, - monitored: selectedSeasonSet.has(season.seasonNumber), - })), - }; - const monitorResp = await fetch(`${SONARR_HOST}/api/v3/series/${result.id}?apikey=${SONARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedSeries), - }); - if (monitorResp.ok) { - addLogStep(logId, `Enabled Sonarr monitoring for seasons ${selectedSeasons.join(', ')}`, 'info'); - } else { - addLogStep(logId, `Sonarr add completed, but season monitoring PUT returned HTTP ${monitorResp.status}`, 'warning'); - } - } catch (err) { - addLogStep(logId, `Failed to confirm Sonarr season monitoring: ${err.message}`, 'warning'); - } - } - const pipelineKey = `sonarr-${result.id}-add-${Date.now()}`; - const subtitle = selectedSeasons ? `Seasons ${selectedSeasons.join(', ')}` : 'All seasons'; - addPipelineItem(pipelineKey, { service: 'sonarr', title: result.title, subtitle, posterUrl: seriesPoster, logId, seriesId: result.id, seasonNumbers: selectedSeasons || null, retryId: result.id }); - advancePipeline(pipelineKey, 'searching'); - const singleSeasonSearch = selectedSeasons?.length === 1; - let searchSeasonNumber = singleSeasonSearch ? selectedSeasons[0] : null; - let cmdBody = singleSeasonSearch - ? { name: 'SeasonSearch', seriesId: result.id, seasonNumber: selectedSeasons[0] } - : { name: 'SeriesSearch', seriesId: result.id }; - if (singleSeasonSearch) { - const searchPlan = await prepareSonarrSearch(result.id, selectedSeasons); - const plannedSeasonSearch = searchPlan.seasonSearches[0]; - cmdBody = plannedSeasonSearch?.cmdBody || cmdBody; - searchSeasonNumber = plannedSeasonSearch?.seasonNumber ?? searchSeasonNumber; - if (searchPlan.episodeMonitoringChanged) { - addLogStep(logId, `Explicitly enabled Sonarr episode monitoring for season ${searchSeasonNumber}`, 'info'); - } - if (plannedSeasonSearch?.mode === 'episode') { - addLogStep(logId, `Searching released episodes individually for season ${searchSeasonNumber}`, 'info'); - } - } - fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cmdBody), - }).then(r => r.json()).then(cmd => { - if (cmd?.id) watchSonarrSearch(pipelineKey, cmd.id, result.id, singleSeasonSearch ? searchSeasonNumber : null, logId, result.title).catch(e => console.error('watchSonarrSearch add:', e.message)); - }).catch(() => {}); - - res.json({ success: true, id: result.id, title: result.title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to add series: ${err.message}` }); - console.error('Add series error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -router.post('/add/music', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const { foreignArtistId, artistName, qualityProfileId, metadataProfileId, rootFolderPath, monitored, monitorOption, searchForMissingAlbums, selectedAlbums, selectedAlbumTitles } = req.body; - if (!foreignArtistId) return res.status(400).json({ error: 'Missing foreignArtistId' }); - - const hasAlbumSelection = selectedAlbumTitles && selectedAlbumTitles.length > 0; - const logId = logActivity('add', `Adding artist "${artistName}" to Lidarr...`, { foreignArtistId }, 'pending', { service: 'lidarr', artistName, foreignArtistId }); - - try { - addLogStep(logId, `Adding "${artistName}" to Lidarr${hasAlbumSelection ? ` (${selectedAlbumTitles.length} albums selected)` : ' (all albums)'}`, 'pending'); - - const addPayload = { - foreignArtistId, - artistName: artistName || '', - qualityProfileId: qualityProfileId || 1, - metadataProfileId: metadataProfileId || 1, - rootFolderPath: rootFolderPath || getDefaultRootFolder('lidarr') || '/music', - monitored: true, - monitorNewItems: 'all', - addOptions: { - monitor: hasAlbumSelection ? 'none' : (monitorOption || 'all'), - searchForMissingAlbums: hasAlbumSelection ? false : (searchForMissingAlbums !== false), - }, - }; - - const resp = await fetch(`${LIDARR_HOST}/api/v1/artist?apikey=${LIDARR_API_KEY}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(addPayload), - }); - - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err[0]?.errorMessage || err.message || `HTTP ${resp.status}`); - } - const addResult = await resp.json(); - addLogStep(logId, `Artist "${addResult.artistName}" added to Lidarr (ID: ${addResult.id})`, 'success'); - - let albums = []; - addLogStep(logId, 'Waiting for Lidarr to import album metadata...', 'pending'); - for (let attempt = 0; attempt < 15; attempt++) { - await new Promise(r => setTimeout(r, 2000)); - try { - albums = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/album?artistId=${addResult.id}&apikey=${LIDARR_API_KEY}`, 10000); - } catch (e) { - console.log(`Album fetch attempt ${attempt + 1} error: ${e.message}`); - } - if (albums.length > 0) break; - } - if (albums.length > 0) { - addLogStep(logId, `Lidarr imported ${albums.length} album(s)`, 'success'); - } else { - addLogStep(logId, 'Warning: Lidarr returned 0 albums after 30s — artist may have no MusicBrainz releases', 'warning'); - } - - const normalize = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') - .replace(/\s*\(.*?\)\s*/g, ' ').replace(/\s*\[.*?\]\s*/g, ' ') - .replace(/ - (single|ep)$/i, '').replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ').trim(); - - const albumsToSearch = []; - let unmatchedAlbumTitles = []; - - if (hasAlbumSelection && albums.length > 0) { - // For curated album picks, import the artist first and opt matching albums into monitoring afterward. - const normalizedSelected = selectedAlbumTitles.map(t => ({ original: t, norm: normalize(t) })); - - const matched = []; - const matchedOriginals = new Set(); - - const albumsWithMatches = albums.map(album => { - const albumNorm = normalize(album.title || ''); - const matchEntry = normalizedSelected.find(sel => sel.norm === albumNorm) || - normalizedSelected.find(sel => { - if (sel.norm.length < 3 || albumNorm.length < 3) return false; - const shorter = sel.norm.length <= albumNorm.length ? sel.norm : albumNorm; - const longer = sel.norm.length > albumNorm.length ? sel.norm : albumNorm; - if (shorter.length / longer.length < 0.6) return false; - return longer.includes(shorter); - }); - return { album, matchEntry }; - }).filter(({ matchEntry }) => matchEntry != null); - - await Promise.all(albumsWithMatches.map(async ({ album, matchEntry }) => { - album.monitored = true; - matchedOriginals.add(matchEntry.original); - try { - await fetch(`${LIDARR_HOST}/api/v1/album/${album.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(album), - }); - matched.push(album.title); - albumsToSearch.push({ id: album.id, title: album.title }); - } catch (e) { - addLogStep(logId, `Failed to monitor "${album.title}": ${e.message}`, 'error'); - } - })); - - const unmatchedSelected = selectedAlbumTitles.filter(t => !matchedOriginals.has(t)); - - if (matched.length > 0) { - addLogStep(logId, `Monitoring ${matched.length}/${selectedAlbumTitles.length} albums: ${matched.join(', ')}`, 'success'); - } else { - addLogStep(logId, `Warning: Could not match any selected albums in Lidarr`, 'warning'); - } - if (unmatchedSelected.length > 0) { - unmatchedAlbumTitles = unmatchedSelected; - addLogStep(logId, `${unmatchedSelected.length} selected album(s) not found in Lidarr: ${unmatchedSelected.join(', ')}`, 'warning'); - } - } else if (albums.length > 0) { - for (const album of albums) { - if (album.monitored) { - albumsToSearch.push({ id: album.id, title: album.title }); - } - } - addLogStep(logId, `All ${albumsToSearch.length} albums monitored`, 'success'); - } - - if (hasAlbumSelection && albums.length === 0) { - unmatchedAlbumTitles = [...selectedAlbumTitles]; - addLogStep(logId, `Lidarr has no album metadata — will search Soulseek directly for ${selectedAlbumTitles.length} album(s)`, 'pending'); - } - - try { - const artistData = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${addResult.id}?apikey=${LIDARR_API_KEY}`, 5000); - if (!artistData.monitored) { - artistData.monitored = true; - const putResp = await fetch(`${LIDARR_HOST}/api/v1/artist/${addResult.id}?apikey=${LIDARR_API_KEY}`, { - method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(artistData), - }); - if (putResp.ok) { - addLogStep(logId, 'Artist monitoring enabled', 'success'); - } else { - addLogStep(logId, `Warning: Could not enable artist monitoring (HTTP ${putResp.status})`, 'warning'); - } - } - } catch (e) { - addLogStep(logId, `Warning: Could not enable artist monitoring: ${e.message}`, 'warning'); - } - - if (albumsToSearch.length > 0) { - try { - const albumIds = albumsToSearch.map(a => a.id); - await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'AlbumSearch', albumIds }), - }); - addLogStep(logId, `Lidarr search triggered for ${albumIds.length} album(s)`, 'success'); - } catch (e) { - addLogStep(logId, `Lidarr search trigger failed: ${e.message}`, 'error'); - } - } - - if ((albumsToSearch.length > 0 || unmatchedAlbumTitles.length > 0) && SLSKD_API_KEY) { - const resolvedArtistName = addResult.artistName || artistName; - const searchAlbums = [ - ...albumsToSearch, - ...unmatchedAlbumTitles.map(t => ({ id: null, title: t })), - ]; - (async () => { - addLogStep(logId, `Starting Soulseek direct search for ${searchAlbums.length} album(s)...`, 'pending'); - let queued = 0; - let failed = 0; - const failedTitles = []; - - for (const album of searchAlbums) { - try { - const dlResult = await searchAndDownloadSlskd(resolvedArtistName, album.title, logId, addResult.id, album.id); - if (dlResult.success) { - queued++; - } else { - failed++; - failedTitles.push(`${album.title} (${dlResult.reason})`); - } - } catch (e) { - failed++; - failedTitles.push(`${album.title} (${e.message})`); - } - if (searchAlbums.indexOf(album) < searchAlbums.length - 1) { - await new Promise(r => setTimeout(r, 1500)); - } - } - - if (queued > 0) { - addLogStep(logId, `Soulseek: Queued downloads for ${queued}/${searchAlbums.length} album(s)`, 'success'); - updateLogEntry(logId, { status: 'success', message: `"${resolvedArtistName}" — ${queued} album(s) downloading via Soulseek` }); - } else if (failed > 0) { - addLogStep(logId, `Soulseek: No downloads queued. Failed: ${failedTitles.join('; ')}. Soularr will retry on next cycle.`, 'warning'); - } - - if (queued > 0) { - console.log('SLSKD downloads queued — auto-import pipeline will handle import when complete'); - } - })().catch(err => { - console.error('SLSKD background search error:', err.message); - addLogStep(logId, `Soulseek background search error: ${err.message}`, 'error'); - }); - } - - const albumInfo = albumsToSearch.length > 0 ? ` (${albumsToSearch.length} albums monitored)` : ''; - updateLogEntry(logId, { status: 'success', message: `Added "${addResult.artistName}" to Lidarr${albumInfo}, searching for downloads...` }); - refreshLibraryCache(); - res.json({ - success: true, id: addResult.id, artistName: addResult.artistName, - albumsMonitored: albumsToSearch.length, totalAlbums: albums.length, - details: albumsToSearch.map(a => a.title), - }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to add artist: ${err.message}` }); - console.error('Add music error:', err.message); - res.status(500).json({ error: err.message }); - } -}); - -// ─── Delete Media ─────────────────────────────────────────────────────────── - -router.delete('/delete/movie/:id', async (req, res) => { - if (!RADARR_API_KEY) return res.status(503).json({ error: 'Radarr not configured' }); - const { id } = req.params; - const deleteFiles = req.query.deleteFiles !== 'false'; - const logId = logActivity('delete', `Deleting movie ID ${id} from Radarr...`, { movieId: id, deleteFiles }, 'pending', { service: 'radarr' }); - try { - let title = `ID ${id}`; - try { - const info = await fetchWithTimeout(`${RADARR_HOST}/api/v3/movie/${id}?apikey=${RADARR_API_KEY}`); - title = info.title || title; - } catch {} - const resp = await fetch(`${RADARR_HOST}/api/v3/movie/${id}?deleteFiles=${deleteFiles}&addImportExclusion=false&apikey=${RADARR_API_KEY}`, { - method: 'DELETE', - }); - if (!resp.ok) { - throw new Error(parseArrError(await resp.text(), resp.status)); - } - updateLogEntry(logId, { status: 'success', message: `Deleted "${title}" from Radarr${deleteFiles ? ' (files removed)' : ''}`, context: { service: 'radarr', title } }); - refreshLibraryCache(); - res.json({ success: true, title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to delete movie: ${err.message}` }); - res.status(500).json({ error: err.message }); - } -}); - -router.delete('/delete/series/:id', async (req, res) => { - if (!SONARR_API_KEY) return res.status(503).json({ error: 'Sonarr not configured' }); - const { id } = req.params; - const deleteFiles = req.query.deleteFiles !== 'false'; - const logId = logActivity('delete', `Deleting series ID ${id} from Sonarr...`, { seriesId: id, deleteFiles }, 'pending', { service: 'sonarr' }); - try { - let title = `ID ${id}`; - try { - const info = await fetchWithTimeout(`${SONARR_HOST}/api/v3/series/${id}?apikey=${SONARR_API_KEY}`); - title = info.title || title; - } catch {} - const resp = await fetch(`${SONARR_HOST}/api/v3/series/${id}?deleteFiles=${deleteFiles}&apikey=${SONARR_API_KEY}`, { - method: 'DELETE', - }); - if (!resp.ok) { - throw new Error(parseArrError(await resp.text(), resp.status)); - } - updateLogEntry(logId, { status: 'success', message: `Deleted "${title}" from Sonarr${deleteFiles ? ' (files removed)' : ''}`, context: { service: 'sonarr', title } }); - refreshLibraryCache(); - res.json({ success: true, title }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to delete series: ${err.message}` }); - res.status(500).json({ error: err.message }); - } -}); - -router.delete('/delete/album/:id/files', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - try { - const trackFiles = await fetchWithTimeout( - `${LIDARR_HOST}/api/v1/trackfile?albumId=${encodeURIComponent(req.params.id)}&apikey=${LIDARR_API_KEY}`, 8000 - ); - if (!Array.isArray(trackFiles) || trackFiles.length === 0) - return res.json({ success: true, deleted: 0 }); - const ids = trackFiles.map(f => f.id); - await fetch(`${LIDARR_HOST}/api/v1/trackfile/bulk?apikey=${LIDARR_API_KEY}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ trackFileIds: ids }), - }); - res.json({ success: true, deleted: ids.length }); - } catch (err) { - console.error('Album file delete error:', err.message); - res.status(502).json({ error: 'Failed to delete album files' }); - } -}); +const router = Router(); -router.delete('/delete/music/:id', async (req, res) => { - if (!LIDARR_API_KEY) return res.status(503).json({ error: 'Lidarr not configured' }); - const { id } = req.params; - const deleteFiles = req.query.deleteFiles !== 'false'; - const logId = logActivity('delete', `Deleting artist ID ${id} from Lidarr...`, { artistId: id, deleteFiles }, 'pending', { service: 'lidarr' }); - try { - let artistName = `ID ${id}`; - try { - const info = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/artist/${id}?apikey=${LIDARR_API_KEY}`); - artistName = info.artistName || artistName; - } catch {} - const resp = await fetch(`${LIDARR_HOST}/api/v1/artist/${id}?deleteFiles=${deleteFiles}&apikey=${LIDARR_API_KEY}`, { - method: 'DELETE', - }); - if (!resp.ok) { - throw new Error(parseArrError(await resp.text(), resp.status)); - } - updateLogEntry(logId, { status: 'success', message: `Deleted "${artistName}" from Lidarr${deleteFiles ? ' (files removed)' : ''}`, context: { service: 'lidarr', artistName } }); - refreshLibraryCache(); - res.json({ success: true, artistName }); - } catch (err) { - updateLogEntry(logId, { status: 'error', message: `Failed to delete artist: ${err.message}` }); - res.status(500).json({ error: err.message }); - } -}); +router.use(cacheSearchRoutes); +router.use(tvRoutes); +router.use(movieRoutes); +router.use(musicRoutes); +router.use(lookupRoutes); +router.use(profileRoutes); +router.use(addRoutes); +router.use(deleteRoutes); export default router; diff --git a/backend/src/routes/media.js b/backend/src/routes/media.js index 513a840..f51d8e7 100644 --- a/backend/src/routes/media.js +++ b/backend/src/routes/media.js @@ -1,8 +1,8 @@ import { Router } from 'express'; import { Readable } from 'stream'; import { CONFIG } from '../config.js'; -import { fetchWithTimeout, normalizeForMatch, pickArrImageUrl, qbFetchJson } from '../utils.js'; -import { logServerEvent, metadataCache, summarizeError } from '../state.js'; +import { fetchWithTimeout, normalizeForMatch, pickArrImageUrl, qbittorrentLogin } from '../utils.js'; +import { metadataCache } from '../state.js'; const router = Router(); @@ -38,24 +38,6 @@ const arrCatalogCache = { }; const sonarrEpisodeArtCache = new Map(); -function summarizeHash(hash) { - return typeof hash === 'string' && hash.length > 8 ? `${hash.slice(0, 8)}…` : hash || null; -} - -function summarizeTarget(url) { - try { - const parsed = new URL(url); - return { - host: parsed.host, - path: parsed.pathname, - }; - } catch { - return { - target: String(url || ''), - }; - } -} - function isAllowedPosterHost(hostname) { return POSTER_ALLOWED_HOSTS.some(host => hostname === host || hostname.endsWith(`.${host}`)); } @@ -63,7 +45,7 @@ function isAllowedPosterHost(hostname) { function getCachedPoster(url) { const hit = posterCache.get(url); if (!hit) return null; - if ((Date.now() - hit.ts) > POSTER_CACHE_TTL_MS) { + if (Date.now() - hit.ts > POSTER_CACHE_TTL_MS) { posterCache.delete(url); return null; } @@ -111,12 +93,7 @@ function uniqueSources(values) { } function extractMatchSources(torrent, files) { - const names = [ - torrent?.name, - torrent?.content_path, - torrent?.save_path, - ...(files || []).map((file) => file?.name), - ]; + const names = [torrent?.name, torrent?.content_path, torrent?.save_path, ...(files || []).map(file => file?.name)]; return uniqueSources(names); } @@ -148,7 +125,7 @@ function scoreMatch(source, sourceCompact, variant, year) { function findBestMatch(items, sources, titleKeys) { if (!Array.isArray(items) || items.length === 0 || sources.length === 0) return null; - const compactSources = sources.map((source) => compactForMatch(source)); + const compactSources = sources.map(source => compactForMatch(source)); let bestMatch = null; let bestScore = -1; @@ -174,10 +151,10 @@ function findBestMatch(items, sources, titleKeys) { async function getArrCatalog(kind, url) { const cache = arrCatalogCache[kind]; const now = Date.now(); - if (cache.data && (now - cache.ts) < ARR_CATALOG_TTL_MS) return cache.data; + if (cache.data && now - cache.ts < ARR_CATALOG_TTL_MS) return cache.data; + // Reuse one Arr catalog fetch across concurrent torrent lookups. if (cache.inflight) return cache.inflight; - // Reuse one Arr catalog fetch across concurrent torrent lookups. cache.inflight = (async () => { try { const data = await fetchWithTimeout(url, 8000); @@ -201,7 +178,7 @@ async function getSonarrEpisodeArt(seriesId, season, episode) { const key = getSonarrEpisodeArtCacheKey(seriesId, season, episode); const cached = sonarrEpisodeArtCache.get(key); - if (cached && (Date.now() - cached.ts) < SONARR_EPISODE_ART_TTL_MS) { + if (cached && Date.now() - cached.ts < SONARR_EPISODE_ART_TTL_MS) { return cached.data; } @@ -214,10 +191,11 @@ async function getSonarrEpisodeArt(seriesId, season, episode) { return null; } - const episodeSummary = episode != null - ? seasonEpisodes.find((entry) => entry.episodeNumber === episode) - : seasonEpisodes.find((entry) => entry.episodeNumber >= 1) || seasonEpisodes[0]; // Season packs borrow the first real episode's art when no episode number is present. + const episodeSummary = + episode != null + ? seasonEpisodes.find(entry => entry.episodeNumber === episode) + : seasonEpisodes.find(entry => entry.episodeNumber >= 1) || seasonEpisodes[0]; if (!episodeSummary?.id) { sonarrEpisodeArtCache.set(key, { ts: Date.now(), data: null }); return null; @@ -240,26 +218,30 @@ async function getSonarrEpisodeArt(seriesId, season, episode) { async function lookupMediaInfo(hash) { const cached = getMetadataCacheEntry(hash); - if (cached) return { status: 'ok', data: cached, source: 'cache' }; - - const hashRef = summarizeHash(hash); + if (cached) return cached; try { - const torrentResp = await qbFetchJson(`/api/v2/torrents/info?hashes=${hash}`); + const { qbHost, cookie } = await qbittorrentLogin(); + const qbHeaders = { Cookie: cookie, Referer: qbHost }; + const torrentResp = await fetchWithTimeout( + `${qbHost}/api/v2/torrents/info?hashes=${encodeURIComponent(hash)}`, + 5000, + qbHeaders, + ); const torrents = Array.isArray(torrentResp) ? torrentResp : []; - if (torrents.length === 0) { - return { status: 'torrent_not_found' }; - } + if (torrents.length === 0) return null; const torrent = torrents[0]; let matchSources = extractMatchSources(torrent, []); let result = null; - const upstreamFailures = []; try { if (CONFIG.RADARR_API_KEY) { - const movies = await getArrCatalog('movies', `${CONFIG.RADARR_HOST}/api/v3/movie?apikey=${CONFIG.RADARR_API_KEY}`); + const movies = await getArrCatalog( + 'movies', + `${CONFIG.RADARR_HOST}/api/v3/movie?apikey=${CONFIG.RADARR_API_KEY}`, + ); if (Array.isArray(movies)) { const match = findBestMatch(movies, matchSources, ['title', 'originalTitle', 'cleanTitle', 'sortTitle']); if (match) { @@ -280,42 +262,45 @@ async function lookupMediaInfo(hash) { } } } - } catch (err) { - upstreamFailures.push({ source: 'radarr', error: summarizeError(err) }); + } catch { + /* radarr lookup failed */ } if (!result && CONFIG.SONARR_API_KEY) { try { if (matchSources.length <= 2) { - const contentResp = await qbFetchJson(`/api/v2/torrents/files?hash=${hash}`); - const files = Array.isArray(contentResp) ? contentResp : []; // qB root names are often too generic for shows; file names give Sonarr better match text. + const contentResp = await fetchWithTimeout( + `${qbHost}/api/v2/torrents/files?hash=${encodeURIComponent(hash)}`, + 5000, + qbHeaders, + ); + const files = Array.isArray(contentResp) ? contentResp : []; matchSources = extractMatchSources(torrent, files); } - const series = await getArrCatalog('series', `${CONFIG.SONARR_HOST}/api/v3/series?apikey=${CONFIG.SONARR_API_KEY}`); + const series = await getArrCatalog( + 'series', + `${CONFIG.SONARR_HOST}/api/v3/series?apikey=${CONFIG.SONARR_API_KEY}`, + ); if (Array.isArray(series)) { const match = findBestMatch(series, matchSources, ['title', 'originalTitle', 'cleanTitle', 'sortTitle']); if (match) { const sourceText = matchSources.join(' '); const episodeMatch = sourceText.match(/\bs(\d{1,2})e(\d{1,2})\b/i); const seasonPackMatch = sourceText.match(/\bs(\d{1,2})(?:\b|(?=\s|$))/i); - const season = episodeMatch ? parseInt(episodeMatch[1], 10) : seasonPackMatch ? parseInt(seasonPackMatch[1], 10) : null; + const season = episodeMatch + ? parseInt(episodeMatch[1], 10) + : seasonPackMatch + ? parseInt(seasonPackMatch[1], 10) + : null; const episode = episodeMatch ? parseInt(episodeMatch[2], 10) : null; let episodeArt = null; if (season != null) { try { episodeArt = await getSonarrEpisodeArt(match.id, season, episode); - } catch (err) { - logServerEvent('warn', 'media.lookup.episode_art_failed', { - service: 'sonarr', - hash: hashRef, - seriesId: match.id, - season, - episode, - error: summarizeError(err), - }); + } catch { episodeArt = null; } } @@ -326,7 +311,11 @@ async function lookupMediaInfo(hash) { type: 'tv', season, episode, - episodeNumber: episodeMatch ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` : season ? `S${String(season).padStart(2, '0')}` : null, + episodeNumber: episodeMatch + ? `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}` + : season + ? `S${String(season).padStart(2, '0')}` + : null, episodeTitle: episodeArt?.episodeTitle || null, year: match.year, tvdbId: match.tvdbId, @@ -340,38 +329,19 @@ async function lookupMediaInfo(hash) { }; } } - } catch (err) { - upstreamFailures.push({ source: 'sonarr', error: summarizeError(err) }); + } catch { + /* sonarr lookup failed */ } } if (result) { setMetadataCacheEntry(hash, result); - return { status: 'ok', data: result, source: 'live' }; - } - - if (upstreamFailures.length > 0) { - logServerEvent('error', 'media.lookup.upstream_failed', { - hash: hashRef, - failures: upstreamFailures, - sourceCount: matchSources.length, - }); - return { - status: 'upstream_error', - failures: upstreamFailures, - }; } - return { status: 'no_match' }; + return result; } catch (err) { - logServerEvent('error', 'media.lookup.failed', { - hash: hashRef, - error: summarizeError(err), - }); - return { - status: 'upstream_error', - failures: [{ source: 'qbittorrent', error: summarizeError(err) }], - }; + console.error(`[media] lookup error for ${hash}:`, err.message); + return null; } } @@ -379,14 +349,15 @@ router.get('/media-info/batch', async (req, res) => { try { const hashes = (req.query.hashes || '').split(',').filter(Boolean); const results = {}; - await Promise.all(hashes.map(async (hash) => { - const lookup = await lookupMediaInfo(hash); - if (lookup.status === 'ok' && lookup.data) { - const info = lookup.data; - const { _fetchedAt, ...clean } = info; - results[hash] = clean; - } - })); + await Promise.all( + hashes.map(async hash => { + const info = await lookupMediaInfo(hash); + if (info) { + const { _fetchedAt, ...clean } = info; + results[hash] = clean; + } + }), + ); res.json(results); } catch (err) { res.status(500).json({ error: err.message }); @@ -395,27 +366,12 @@ router.get('/media-info/batch', async (req, res) => { router.get('/media-info/:hash', async (req, res) => { try { - const lookup = await lookupMediaInfo(req.params.hash); - if (lookup.status === 'upstream_error') { - return res.status(502).json({ - error: 'Media lookup upstream failure', - reason: 'upstream_error', - }); - } - if (lookup.status !== 'ok' || !lookup.data) { - return res.status(404).json({ - error: 'Not found', - reason: lookup.status, - }); - } - const info = lookup.data; + const info = await lookupMediaInfo(req.params.hash); + if (!info) return res.status(404).json({ error: 'Not found' }); const { _fetchedAt, ...clean } = info; res.json(clean); } catch (err) { - logServerEvent('error', 'media.lookup.route_failed', { - hash: summarizeHash(req.params.hash), - error: summarizeError(err), - }); + console.error('[media-info] error:', err.message); res.status(500).json({ error: err.message }); } }); @@ -439,14 +395,9 @@ router.get('/media-cache-stats', (req, res) => { router.get('/poster', async (req, res) => { const { url } = req.query; if (!url) return res.status(400).json({ error: 'Missing url' }); - let parsed; try { - parsed = new URL(url); + const parsed = new URL(url); if (!isAllowedPosterHost(parsed.hostname)) { - logServerEvent('warn', 'media.poster.host_rejected', { - host: parsed.hostname, - path: parsed.pathname, - }); return res.status(403).json({ error: 'Domain not allowed' }); } const cached = getCachedPoster(url); @@ -456,20 +407,10 @@ router.get('/poster', async (req, res) => { res.set('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800'); return res.end(cached.buffer); } - const resp = await fetch(url, { headers: { 'User-Agent': 'vibarr/1.0' } }); - if (!resp.ok) { - logServerEvent('error', 'media.poster.fetch_failed', { - ...summarizeTarget(url), - status: resp.status, - }); - return res.status(502).json({ error: 'Failed to fetch poster' }); - } + const resp = await fetch(url, { headers: { 'User-Agent': 'arr-dashboard/1.0' } }); + if (!resp.ok) return res.status(502).json({ error: 'Failed to fetch poster' }); const contentType = resp.headers.get('content-type') || 'image/jpeg'; if (!contentType.startsWith('image/')) { - logServerEvent('error', 'media.poster.non_image_response', { - ...summarizeTarget(url), - contentType, - }); return res.status(502).json({ error: 'Poster upstream did not return an image' }); } const buffer = Buffer.from(await resp.arrayBuffer()); @@ -479,12 +420,7 @@ router.get('/poster', async (req, res) => { res.set('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800'); res.end(buffer); } catch (err) { - const event = parsed ? 'media.poster.request_failed' : 'media.poster.invalid_url'; - logServerEvent(parsed ? 'error' : 'warn', event, { - ...(parsed ? summarizeTarget(url) : { target: String(url) }), - error: summarizeError(err), - }); - res.status(parsed ? 502 : 400).json({ error: parsed ? 'Failed to fetch poster' : 'Invalid URL' }); + res.status(400).json({ error: 'Invalid URL' }); } }); @@ -507,32 +443,15 @@ router.get('/arr-image/:service/*', async (req, res) => { const query = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''; const url = `${config.host}${basePath}${query}`; const resp = await fetch(url, { headers: { 'X-Api-Key': config.key } }); - if (!resp.ok) { - logServerEvent('error', 'media.arr_image.fetch_failed', { - service, - path: basePath, - status: resp.status, - }); - return res.status(502).json({ error: 'Failed to fetch image' }); - } + if (!resp.ok) return res.status(502).json({ error: 'Failed to fetch image' }); const contentType = resp.headers.get('content-type') || 'image/jpeg'; if (!contentType.startsWith('image/')) { - logServerEvent('error', 'media.arr_image.non_image_response', { - service, - path: basePath, - contentType, - }); return res.status(502).json({ error: 'Arr image upstream did not return an image' }); } res.set('Content-Type', contentType); res.set('Cache-Control', 'public, max-age=86400'); Readable.fromWeb(resp.body).pipe(res); } catch (err) { - logServerEvent('error', 'media.arr_image.request_failed', { - service, - path: imagePath.startsWith('/') ? imagePath : `/${imagePath}`, - error: summarizeError(err), - }); res.status(502).json({ error: err.message }); } }); diff --git a/backend/src/routes/pipeline.js b/backend/src/routes/pipeline.js index ebec167..1bfe2a3 100644 --- a/backend/src/routes/pipeline.js +++ b/backend/src/routes/pipeline.js @@ -1,1455 +1 @@ -import { Router } from 'express'; -import { CONFIG, TIMING } from '../config.js'; -import { fetchWithTimeout, pickArrImageUrl, qbittorrentLogin } from '../utils.js'; -import { scheduleLibraryRefresh } from '../libraryRefresh.js'; -import { - getPipeline, getPipelineItem, addPipelineItem, advancePipeline, - completePipeline, removePipelineItem, setPipelineStuck, - addLogStep, updateLogEntry, logActivity, - getActivityLog, logServerEvent, registerInterval, summarizeError, -} from '../state.js'; - -const router = Router(); - -const { RADARR_HOST, RADARR_API_KEY, SONARR_HOST, SONARR_API_KEY, LIDARR_HOST, LIDARR_API_KEY, SLSKD_API_KEY, SLSKD_HOST, PROWLARR_HOST, PROWLARR_API_KEY } = CONFIG; - -// Transient watcher state lingers past pipeline updates so /pending-searches can expose terminal search outcomes. -const pendingSearches = new Map(); - -const STUCK_THRESHOLDS = { - searching: 5 * 60 * 1000, - grabbed: 2 * 60 * 1000, - downloading: 5 * 60 * 1000, - importing: 5 * 60 * 1000, -}; - -const STUCK_REASONS = { - searching_timeout: 'No releases found. Content may not be available digitally yet, or the quality profile is too restrictive.', - searching_no_results: 'No releases grabbed. Content may not have a digital release yet. Radarr/Sonarr will retry automatically when indexers find a release.', - grabbed_timeout: 'Grabbed release not appearing in download client. Check qBittorrent connection and logs.', - downloading_stalled: 'Download stalled — no active peers. This torrent may have very few seeders.', - importing_timeout: 'Import taking longer than usual. Check available disk space and file permissions.', -}; - -const ARR_COMMAND_POLL_INTERVAL_MS = 5000; -const ARR_COMMAND_STATUS_STEP_INTERVAL_MS = 20000; -const DIRECT_RELEASE_TIMEOUT_MS = 45000; -const LIBRARY_REFRESH_DELAY_MS = 15000; - -function releaseQualityName(release) { - return release?.quality?.quality?.name || release?.quality?.name || release?.qualityVersion || 'Unknown'; -} - -function releaseShortTitle(release, max = 96) { - const title = release?.title || release?.sourceTitle || 'release'; - return title.length > max ? `${title.slice(0, max - 1)}…` : title; -} - -function categorizeRejection(reason = '') { - const lower = String(reason).toLowerCase(); - if (lower.includes('alias')) return 'title alias conflict'; - if (lower.includes('seeders')) return 'no seeders'; - if (lower.includes('not wanted in profile') || lower.includes('quality')) return 'quality profile mismatch'; - if (lower.includes('unknown')) return 'unrecognized release'; - if (lower.includes('wrong season')) return 'wrong season'; - if (lower.includes('existing file')) return 'already at cutoff quality'; - if (lower.includes('episode wasn')) return 'episode not monitored'; - return 'other'; -} - -function summarizeReleaseRejections(releases) { - if (!Array.isArray(releases) || releases.length === 0) return null; - const approved = releases.filter(r => !r.rejected); - if (approved.length > 0) { - return `${approved.length} release${approved.length !== 1 ? 's' : ''} approved — may be grabbing now, check downloads`; - } - - const counts = {}; - for (const r of releases) { - const reasons = r.rejections?.length ? r.rejections : ['other']; - for (const reason of reasons) { - const cat = categorizeRejection(reason); - counts[cat] = (counts[cat] || 0) + 1; - } - } - - const parts = Object.entries(counts) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([k, v]) => `${v} ${k}`); - const topSeeded = releases - .slice() - .sort((a, b) => (b.seeders || 0) - (a.seeders || 0))[0]; - const topReasons = (topSeeded?.rejections || []).slice(0, 2).join('; '); - const topBits = topSeeded - ? [`top seeded: ${releaseShortTitle(topSeeded)}`, `${topSeeded.seeders || 0} seeders`, releaseQualityName(topSeeded), topReasons].filter(Boolean) - : []; - - return `${releases.length} found, all rejected — ${parts.join(', ')}${topBits.length ? `. ${topBits.join(' — ')}` : ''}`; -} - -async function fetchRejectionSummary(releaseUrl) { - try { - const releases = await fetchWithTimeout(releaseUrl, 30000); - return summarizeReleaseRejections(releases); - } catch { - return null; - } -} - -function addPipelineStep(key, message) { - const item = getPipelineItem(key); - if (!item) return; - if (!item.steps) item.steps = []; - const now = Date.now(); - item.statusDetail = message; - item.statusUpdatedAt = now; - const latest = item.steps[item.steps.length - 1]; - if (latest?.message === message) { - latest.ts = now; - return; - } - item.steps.push({ ts: now, message }); - if (item.steps.length > 80) item.steps.shift(); -} - -function markPipelineNoResults(key, message, logId, retryable = true) { - const entry = pendingSearches.get(key); - if (entry) Object.assign(entry, { status: 'no_results', error: message }); - addPipelineStep(key, message); - setPipelineStuck(key, message); - const item = getPipelineItem(key); - if (item) { - item.canRetry = retryable; - item.stuckAt = Date.now(); - } - if (logId) addLogStep(logId, message, 'warning'); -} - -function getArrCommandState(cmd) { - const raw = String(cmd?.state || cmd?.status || '').toLowerCase(); - if (raw === 'completed' || raw === 'complete') return 'completed'; - if (raw === 'failed' || raw === 'failure' || raw === 'error') return 'failed'; - return raw || 'running'; -} - -function describeArrCommand(serviceName, cmd) { - const commandName = cmd?.name || cmd?.commandName || `${serviceName} command`; - const state = getArrCommandState(cmd); - const message = cmd?.message || cmd?.statusMessage || cmd?.body?.completionMessage || state; - return `${serviceName} ${commandName}: ${message}`; -} - -function maybeAddCommandProgress(key, serviceName, cmd, tracker) { - const now = Date.now(); - const detail = describeArrCommand(serviceName, cmd); - if (detail !== tracker.lastDetail || now - tracker.lastAt >= ARR_COMMAND_STATUS_STEP_INTERVAL_MS) { - addPipelineStep(key, detail); - tracker.lastDetail = detail; - tracker.lastAt = now; - } -} - -function selectApprovedRelease(releases) { - if (!Array.isArray(releases)) return null; - return releases - .filter(release => !release.rejected && (release.seeders == null || release.seeders > 0)) - .sort((a, b) => (b.seeders || 0) - (a.seeders || 0) || (b.size || 0) - (a.size || 0))[0] || null; -} - -function safeErrorMessage(error) { - return summarizeError(error).message.replace(/apikey=[^&\s]+/gi, 'apikey=[redacted]'); -} - -function readPipelineContext(key, overrides = {}) { - const item = getPipelineItem(key); - return { - pipelineKey: key, - service: overrides.service || item?.service || null, - title: overrides.title || item?.title || null, - stage: overrides.stage || item?.stage || null, - logId: overrides.logId || item?.logId || null, - queueId: overrides.queueId || item?.queueId || null, - retryId: overrides.retryId || item?.retryId || null, - ...overrides, - }; -} - -function markPipelineFailure(key, { - event, - message, - error, - logId, - pendingStatus = 'error', - retryable = true, - context = {}, -}) { - const effectiveMessage = message || summarizeError(error).message; - const entry = pendingSearches.get(key); - if (entry) Object.assign(entry, { status: pendingStatus, error: effectiveMessage }); - setPipelineStuck(key, effectiveMessage); - const item = getPipelineItem(key); - if (item) { - item.canRetry = retryable; - item.stuckAt = Date.now(); - } - addPipelineStep(key, effectiveMessage); - const targetLogId = logId || item?.logId; - if (targetLogId) addLogStep(targetLogId, effectiveMessage, 'error'); - logServerEvent('error', event, { - ...readPipelineContext(key, { ...context, logId: targetLogId }), - error: summarizeError(error), - }); -} - -function handleWatcherCrash(key, watcher, error, context = {}) { - markPipelineFailure(key, { - event: 'pipeline.watcher.unhandled_error', - message: `${watcher} watcher crashed: ${summarizeError(error).message}`, - error, - context: { watcher, ...context }, - }); - setTimeout(() => pendingSearches.delete(key), 60000); -} - -async function readUpstreamErrorMessage(resp, fallback) { - const text = await resp.text().catch(() => ''); - if (!text) return fallback; - try { - const parsed = JSON.parse(text); - const message = Array.isArray(parsed) - ? parsed[0]?.errorMessage || parsed[0]?.message - : parsed?.message || parsed?.errorMessage; - if (message) return summarizeError(new Error(message)).message; - } catch { - // Fall through to the raw text summary. - } - return summarizeError(new Error(text)).message || fallback; -} - -function hasEpisodeAired(episode, now = Date.now()) { - const airValue = episode?.airDateUtc || episode?.airDate; - if (!airValue) return false; - const airTime = Date.parse(airValue); - return Number.isFinite(airTime) && airTime <= now; -} - -export async function prepareSonarrSearch(seriesId, seasonNumbers = null) { - const normalizedSeasonNumbers = Array.isArray(seasonNumbers) && seasonNumbers.length > 0 - ? [...new Set(seasonNumbers.map(Number).filter(Number.isFinite))] - : null; - const seasonSet = normalizedSeasonNumbers ? new Set(normalizedSeasonNumbers) : null; - - const seriesResp = await fetch(`${SONARR_HOST}/api/v3/series/${seriesId}?apikey=${SONARR_API_KEY}`); - if (!seriesResp.ok) { - throw new Error(await readUpstreamErrorMessage(seriesResp, `Failed to fetch series from Sonarr: HTTP ${seriesResp.status}`)); - } - const seriesData = await seriesResp.json(); - - let seriesMonitoringChanged = false; - if (!seriesData.monitored) { - seriesData.monitored = true; - seriesMonitoringChanged = true; - } - for (const season of (seriesData.seasons || [])) { - if (season.seasonNumber === 0) continue; - const targeted = seasonSet ? seasonSet.has(season.seasonNumber) : true; - if (targeted && !season.monitored) { - season.monitored = true; - seriesMonitoringChanged = true; - } - } - if (seriesMonitoringChanged) { - const putResp = await fetch(`${SONARR_HOST}/api/v3/series/${seriesId}?apikey=${SONARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(seriesData), - }); - if (!putResp.ok) { - throw new Error(await readUpstreamErrorMessage(putResp, `Failed to update Sonarr monitoring: HTTP ${putResp.status}`)); - } - } - - const allEpisodes = await fetchWithTimeout(`${SONARR_HOST}/api/v3/episode?seriesId=${seriesId}&apikey=${SONARR_API_KEY}`, 15000); - const targetEpisodes = (Array.isArray(allEpisodes) ? allEpisodes : []).filter((episode) => ( - episode?.seasonNumber > 0 && (!seasonSet || seasonSet.has(episode.seasonNumber)) - )); - const episodeIdsToMonitor = targetEpisodes - .filter((episode) => !episode.monitored) - .map((episode) => episode.id) - .filter(Number.isFinite); - - if (episodeIdsToMonitor.length > 0) { - const monitorResp = await fetch(`${SONARR_HOST}/api/v3/episode/monitor?apikey=${SONARR_API_KEY}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ episodeIds: episodeIdsToMonitor, monitored: true }), - }); - if (!monitorResp.ok) { - throw new Error(await readUpstreamErrorMessage(monitorResp, `Failed to update Sonarr episode monitoring: HTTP ${monitorResp.status}`)); - } - } - - const episodesBySeason = new Map(); - for (const episode of targetEpisodes) { - const effectiveEpisode = episodeIdsToMonitor.includes(episode.id) - ? { ...episode, monitored: true } - : episode; - const seasonBucket = episodesBySeason.get(effectiveEpisode.seasonNumber) || []; - seasonBucket.push(effectiveEpisode); - episodesBySeason.set(effectiveEpisode.seasonNumber, seasonBucket); - } - - const continuingSeries = seriesData.status === 'continuing' || seriesData.ended === false; - const requestedSeasons = normalizedSeasonNumbers || [...episodesBySeason.keys()].sort((a, b) => a - b); - const seasonSearches = requestedSeasons.map((seasonNumber) => { - const seasonEpisodes = episodesBySeason.get(seasonNumber) || []; - const missingReleasedEpisodeIds = seasonEpisodes - .filter((episode) => episode.monitored && !episode.hasFile && hasEpisodeAired(episode)) - .map((episode) => episode.id) - .filter(Number.isFinite); - const useEpisodeSearch = continuingSeries && missingReleasedEpisodeIds.length > 0; - return { - seasonNumber, - mode: useEpisodeSearch ? 'episode' : 'season', - missingReleasedEpisodeIds, - cmdBody: useEpisodeSearch - ? { name: 'EpisodeSearch', episodeIds: missingReleasedEpisodeIds } - : { name: 'SeasonSearch', seriesId, seasonNumber }, - }; - }); - - return { - seriesData, - seriesMonitoringChanged, - episodeMonitoringChanged: episodeIdsToMonitor.length > 0, - seasonSearches, - }; -} - -export async function watchSonarrSearch(pendingKey, commandId, seriesId, seasonNumber, logId, seriesTitle) { - const searchStart = Date.now(); - const deadline = searchStart + 4 * 60 * 1000; - - addPipelineStep(pendingKey, 'Sonarr command submitted — polling for completion…'); - - let commandCompleted = false; - let firstPoll = true; - const progressTracker = { lastDetail: '', lastAt: 0 }; - while (Date.now() < deadline) { - if (!firstPoll) await new Promise(r => setTimeout(r, ARR_COMMAND_POLL_INTERVAL_MS)); - firstPoll = false; - try { - const cmd = await fetchWithTimeout(`${SONARR_HOST}/api/v3/command/${commandId}?apikey=${SONARR_API_KEY}`, 8000); - maybeAddCommandProgress(pendingKey, 'Sonarr', cmd, progressTracker); - const cmdState = getArrCommandState(cmd); - if (cmdState === 'failed') { - const msg = cmd.exception || cmd.message || 'Search command failed'; - addLogStep(logId, `Sonarr search failed: ${msg}`, 'error'); - const entry = pendingSearches.get(pendingKey); - if (entry) Object.assign(entry, { status: 'error', error: msg }); - setPipelineStuck(pendingKey, msg); - const item = getPipelineItem(pendingKey); - if (item) item.canRetry = true; - setTimeout(() => pendingSearches.delete(pendingKey), 30000); - return; - } - if (cmdState === 'completed') { commandCompleted = true; break; } - } catch { /* continue polling */ } - } - - if (!commandCompleted) { - const timeoutMsg = 'Sonarr search command timed out — indexers may be slow or unavailable'; - addLogStep(logId, timeoutMsg, 'warning'); - markPipelineNoResults(pendingKey, timeoutMsg, null); - setTimeout(() => pendingSearches.delete(pendingKey), 120000); - return; - } - - addPipelineStep(pendingKey, 'Sonarr finished querying indexers — waiting for grab history…'); - let grabs = []; - const historyDeadline = Date.now() + 30000; - // Sonarr can finish the search command before the matching grab shows up in history. - while (Date.now() < historyDeadline && grabs.length === 0) { - await new Promise(r => setTimeout(r, 3000)); - try { - const histCheck = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/history?pageSize=50&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, - 8000 - ); - grabs = (histCheck.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.seriesId !== seriesId) return false; - if (seasonNumber != null && h.episode?.seasonNumber !== seasonNumber) return false; - return true; - }); - } catch { /* continue polling */ } - } - - if (grabs.length === 0) { - try { - const history = await fetchWithTimeout( - `${SONARR_HOST}/api/v3/history?pageSize=50&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, - 8000 - ); - grabs = (history.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.seriesId !== seriesId) return false; - if (seasonNumber != null && h.episode?.seasonNumber !== seasonNumber) return false; - return true; - }); - } catch { /* ignore */ } - } - - try { - const entry = pendingSearches.get(pendingKey); - if (grabs.length > 0) { - const titles = grabs.map(g => g.sourceTitle || 'release').slice(0, 2).join(', '); - addLogStep(logId, `Grabbed ${grabs.length} episode(s): ${titles}`, 'success'); - addPipelineStep(pendingKey, `Grabbed ${grabs.length} release(s) — sending to qBittorrent`); - if (entry) Object.assign(entry, { status: 'grabbed' }); - advancePipeline(pendingKey, 'grabbed'); - setTimeout(() => pendingSearches.delete(pendingKey), 90000); - } else { - const releaseUrl = seasonNumber != null - ? `${SONARR_HOST}/api/v3/release?seriesId=${seriesId}&seasonNumber=${seasonNumber}&apikey=${SONARR_API_KEY}` - : `${SONARR_HOST}/api/v3/release?seriesId=${seriesId}&apikey=${SONARR_API_KEY}`; - const rejSummary = await fetchRejectionSummary(releaseUrl); - const noResultsMsg = rejSummary ? `No grab — ${rejSummary}` : 'No matching releases found — check indexers or episode availability'; - markPipelineNoResults(pendingKey, noResultsMsg, logId); - setTimeout(() => pendingSearches.delete(pendingKey), 120000); - } - } catch (err) { - markPipelineFailure(pendingKey, { - event: 'pipeline.search.transition_failed', - message: 'Sonarr search finished, but grab history could not be confirmed', - error: err, - logId, - context: { - service: 'sonarr', - title: seriesTitle, - commandId, - seriesId, - seasonNumber, - phase: 'history_confirmation', - }, - }); - setTimeout(() => pendingSearches.delete(pendingKey), 120000); - } -} - -export async function watchRadarrSearch(pipelineKey, commandId, movieId, logId, movieTitle) { - const searchStart = Date.now(); - const deadline = searchStart + 4 * 60 * 1000; - - addPipelineStep(pipelineKey, 'Radarr command submitted — polling for completion…'); - - let commandCompleted = false; - let firstPoll = true; - const progressTracker = { lastDetail: '', lastAt: 0 }; - while (Date.now() < deadline) { - if (!firstPoll) await new Promise(r => setTimeout(r, ARR_COMMAND_POLL_INTERVAL_MS)); - firstPoll = false; - try { - const cmd = await fetchWithTimeout(`${RADARR_HOST}/api/v3/command/${commandId}?apikey=${RADARR_API_KEY}`, 8000); - maybeAddCommandProgress(pipelineKey, 'Radarr', cmd, progressTracker); - const cmdState = getArrCommandState(cmd); - if (cmdState === 'failed') { - const msg = cmd.exception || cmd.message || 'Radarr search command failed'; - const entry = pendingSearches.get(pipelineKey); - if (entry) Object.assign(entry, { status: 'error', error: msg }); - setPipelineStuck(pipelineKey, msg); - const item = getPipelineItem(pipelineKey); - if (item) item.canRetry = true; - if (logId) addLogStep(logId, `Radarr search failed: ${msg}`, 'error'); - setTimeout(() => pendingSearches.delete(pipelineKey), 30000); - return; - } - if (cmdState === 'completed') { commandCompleted = true; break; } - } catch { /* continue polling */ } - } - - if (!commandCompleted) { - const timeoutMsg = 'Radarr search command timed out — indexers may be slow or unavailable'; - if (logId) addLogStep(logId, timeoutMsg, 'warning'); - markPipelineNoResults(pipelineKey, timeoutMsg, null); - setTimeout(() => pendingSearches.delete(pipelineKey), 120000); - return; - } - - addPipelineStep(pipelineKey, 'Radarr finished querying indexers — waiting for grab history…'); - let grabs = []; - const historyDeadline = Date.now() + 30000; - // Radarr can finish the search command before the matching grab shows up in history. - while (Date.now() < historyDeadline && grabs.length === 0) { - await new Promise(r => setTimeout(r, 3000)); - try { - const histCheck = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/history?pageSize=50&includeMovie=true&apikey=${RADARR_API_KEY}`, - 8000 - ); - grabs = (histCheck.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.movieId !== movieId) return false; - return true; - }); - } catch { /* continue polling */ } - } - - if (grabs.length === 0) { - try { - const history = await fetchWithTimeout( - `${RADARR_HOST}/api/v3/history?pageSize=50&includeMovie=true&apikey=${RADARR_API_KEY}`, - 8000 - ); - grabs = (history.records || []).filter(h => { - if (h.eventType !== 'grabbed') return false; - if (new Date(h.date).getTime() < searchStart) return false; - if (h.movieId !== movieId) return false; - return true; - }); - } catch { /* ignore */ } - } - - try { - const entry = pendingSearches.get(pipelineKey); - if (grabs.length > 0) { - const sourceTitle = grabs[0].sourceTitle || 'release'; - addPipelineStep(pipelineKey, `Grabbed: ${sourceTitle} — sending to qBittorrent`); - if (entry) Object.assign(entry, { status: 'grabbed' }); - advancePipeline(pipelineKey, 'grabbed'); - if (logId) addLogStep(logId, `Grabbed: ${sourceTitle}`, 'success'); - setTimeout(() => pendingSearches.delete(pipelineKey), 90000); - } else { - const releaseUrl = `${RADARR_HOST}/api/v3/release?movieId=${movieId}&apikey=${RADARR_API_KEY}`; - const rejSummary = await fetchRejectionSummary(releaseUrl); - const noResultsMsg = rejSummary ? `No grab — ${rejSummary}` : 'No matching releases found — check indexers or availability'; - markPipelineNoResults(pipelineKey, noResultsMsg, logId); - setTimeout(() => pendingSearches.delete(pipelineKey), 120000); - } - } catch (err) { - markPipelineFailure(pipelineKey, { - event: 'pipeline.search.transition_failed', - message: 'Radarr search finished, but grab history could not be confirmed', - error: err, - logId, - context: { - service: 'radarr', - title: movieTitle, - commandId, - movieId, - phase: 'history_confirmation', - }, - }); - setTimeout(() => pendingSearches.delete(pipelineKey), 120000); - } -} - -export async function arrGrab({ service, host, apiKey, guid, indexerId, downloadUrl, title }) { - const endpoint = downloadUrl ? 'release/push' : 'release'; - const body = downloadUrl - ? { title, downloadUrl, protocol: 'torrent', ...(indexerId ? { indexerId } : {}) } - : { guid, indexerId }; - const resp = await fetch(`${host}/api/v3/${endpoint}?apikey=${apiKey}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!resp.ok) { - const msg = await readUpstreamErrorMessage(resp, `${service} grab failed: HTTP ${resp.status}`); - logServerEvent('error', 'pipeline.grab.request_failed', { - service: service.toLowerCase(), - endpoint, - status: resp.status, - title: title || null, - hasDownloadUrl: Boolean(downloadUrl), - indexerId: indexerId || null, - error: summarizeError(new Error(msg)), - }); - throw new Error(msg || `${service} grab failed: HTTP ${resp.status}`); - } - logServerEvent('info', 'pipeline.grab.requested', { - service: service.toLowerCase(), - endpoint, - title: title || null, - hasDownloadUrl: Boolean(downloadUrl), - indexerId: indexerId || null, - }); - return resp.json(); -} - -function checkPipelineStuck() { - try { - const now = Date.now(); - const items = getPipeline(); - for (const item of items) { - if (item.stage === 'complete' || item.stage === 'failed') continue; - if (item.stage === 'stuck' && item.stuckAt && (now - item.stuckAt > 30 * 60 * 1000)) { - removePipelineItem(item.key); - continue; - } - if (item.stage === 'stuck') continue; - const threshold = STUCK_THRESHOLDS[item.stage]; - if (!threshold) continue; - const stageStartedAt = item.stageStartedAt || item.stageChangedAt || item.startedAt || item.createdAt || now; - if (now - stageStartedAt > threshold) { - const reason = STUCK_REASONS[`${item.stage}_timeout`] || `Taking longer than expected at stage: ${item.stage}`; - setPipelineStuck(item.key, reason); - // Leave stuck cards visible for manual retry/debug before aging them out separately. - const i = getPipelineItem(item.key); - if (i) { i.canRetry = item.stage === 'searching' || item.stage === 'grabbed'; i.stuckAt = Date.now(); } - if (item.logId) addLogStep(item.logId, `Stuck at "${item.stage}": ${reason}`, 'warning'); - } - } - } catch (e) { - logServerEvent('error', 'pipeline.monitor.stuck_check_failed', { - error: summarizeError(e), - }); - } -} - -registerInterval(setInterval(checkPipelineStuck, TIMING.CHECK_STUCK_INTERVAL_MS || 30000)); - -const queueAlerts = new Map(); - -export function restoreQueueAlertsFromActivityLog() { - queueAlerts.clear(); - for (const entry of getActivityLog()) { - if (entry?.type !== 'queue' || entry?.status !== 'error') continue; - const service = entry.details?.service || entry.context?.service; - const discriminator = entry.details?.downloadId || entry.details?.queueId || null; - if (!service || !discriminator) continue; - const key = `${service}-q-${discriminator}`; - if (!queueAlerts.has(key)) { - queueAlerts.set(key, { logId: entry.id, status: 'error' }); - } - } -} - -export async function monitorQueues() { - const tasks = []; - - if (LIDARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${LIDARR_HOST}/api/v1/queue?page=1&pageSize=100&includeArtist=true&includeAlbum=true&apikey=${LIDARR_API_KEY}`, 8000); - const lidarrCounts = new Map(); - for (const r of (data.records || [])) { - const dlid = r.downloadId || `__id_${r.id}`; - lidarrCounts.set(dlid, (lidarrCounts.get(dlid) || 0) + 1); - } - const seenLidarr = new Set(); - for (const item of (data.records || [])) { - const dlid = item.downloadId || `__id_${item.id}`; - if (seenLidarr.has(dlid)) continue; - seenLidarr.add(dlid); - const key = `lidarr-q-${dlid}`; - const artist = item.artist?.artistName || 'Unknown'; - const album = item.album?.title || 'Unknown'; - const trackCount = lidarrCounts.get(dlid) || 1; - const label = trackCount > 1 ? `${artist} - ${album} (${trackCount} tracks)` : `${artist} - ${album}`; - const hasError = item.status === 'warning' || item.status === 'error' || item.trackedDownloadStatus === 'warning' || item.trackedDownloadStatus === 'error' || (item.statusMessages?.length > 0); - const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); - const errorDetail = item.errorMessage || msgs.join('; ') || ''; - - if (hasError && !queueAlerts.has(key)) { - const errorDetails = { - status: item.status, - trackedDownloadStatus: item.trackedDownloadStatus, - trackedDownloadState: item.trackedDownloadState, - protocol: item.protocol, - errorMessage: item.errorMessage || null, - statusMessages: (item.statusMessages || []).map(m => ({ - title: m.title, - messages: m.messages || [], - })), - }; - const logId = logActivity('queue', `${label}: ${errorDetail || 'download issue'}`, errorDetails, 'error', { service: 'lidarr', artistName: artist, title: album }); - queueAlerts.set(key, { logId, status: 'error' }); - } else if (!hasError && queueAlerts.has(key)) { - const alert = queueAlerts.get(key); - updateLogEntry(alert.logId, { status: 'success', message: `${label}: resolved` }); - queueAlerts.delete(key); - } - - if (item.status === 'downloading' && item.trackedDownloadStatus === 'ok') { - const pKey = `lidarr-dl-${dlid}`; - if (!queueAlerts.has(pKey)) { - const protocol = item.protocol === 'torrent' ? 'torrent' : 'Soulseek'; - const logId = logActivity('download', `Downloading ${label} via ${protocol}`, { service: 'lidarr', queueId: item.id, downloadId: item.downloadId, protocol: item.protocol }, 'pending', { service: 'lidarr', artistName: artist, title: album }); - queueAlerts.set(pKey, { logId, status: 'pending' }); - } - } - } - - const activeLidarrDlids = new Set((data.records || []).map(r => r.downloadId || `__id_${r.id}`)); - for (const [key, alert] of queueAlerts) { - if (key.startsWith('lidarr-dl-') && alert.status === 'pending') { - const trackedDlid = key.replace('lidarr-dl-', ''); - const stillInQueue = activeLidarrDlids.has(trackedDlid); - if (!stillInQueue) { - const logEntry = getActivityLog().find(e => e.id === alert.logId); - updateLogEntry(alert.logId, { status: 'success', message: logEntry?.message?.replace('Downloading', 'Downloaded') || 'Download completed' }); - queueAlerts.delete(key); - } - } - } - } catch (err) { - logServerEvent('error', 'pipeline.queue_monitor.failed', { - service: 'lidarr', - error: summarizeError(err), - }); - } - })()); - } - - if (SONARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${SONARR_HOST}/api/v3/queue?page=1&pageSize=100&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, 8000); - const sonarrCounts = new Map(); - for (const r of (data.records || [])) { - const dlid = r.downloadId || `__id_${r.id}`; - sonarrCounts.set(dlid, (sonarrCounts.get(dlid) || 0) + 1); - } - const seenSonarr = new Set(); - for (const item of (data.records || [])) { - const dlid = item.downloadId || `__id_${item.id}`; - const isFirstForDlid = !seenSonarr.has(dlid); - if (isFirstForDlid) seenSonarr.add(dlid); - const key = `sonarr-q-${dlid}`; - const series = item.series?.title || 'Unknown'; - const ep = item.episode ? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}` : ''; - const epCount = sonarrCounts.get(dlid) || 1; - const label = epCount > 1 ? `${series} (${epCount} episodes)` : `${series} ${ep}`.trim(); - const hasError = item.status === 'warning' || item.status === 'error' || item.trackedDownloadStatus === 'warning' || item.trackedDownloadStatus === 'error' || (item.statusMessages?.length > 0); - const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); - const errorDetail = item.errorMessage || msgs.join('; ') || ''; - - if (isFirstForDlid && hasError && !queueAlerts.has(key)) { - const logId = logActivity('queue', `${label}: ${errorDetail || 'download issue'}`, { service: 'sonarr', queueId: item.id, downloadId: item.downloadId, recordCount: epCount }, 'error', { service: 'sonarr', title: series }); - queueAlerts.set(key, { logId, status: 'error' }); - } else if (isFirstForDlid && !hasError && queueAlerts.has(key)) { - const alert = queueAlerts.get(key); - updateLogEntry(alert.logId, { status: 'success', message: `${label}: resolved` }); - queueAlerts.delete(key); - } - - const queueSeriesId = item.seriesId || item.series?.id; - const pipelineItems = getPipeline(); - for (const pItem of pipelineItems) { - if (pItem.service !== 'sonarr' || !pItem.seriesId || pItem.seriesId !== queueSeriesId) continue; - if (pItem.stage === 'grabbed' || pItem.stage === 'searching' || pItem.stage === 'stuck') { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : 0; - advancePipeline(pItem.key, 'downloading', { queueId: item.id, progress }); - } else if (pItem.stage === 'downloading' && pItem.queueId === item.id) { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : pItem.progress; - const updated = getPipelineItem(pItem.key); - if (updated) updated.progress = progress; - if (hasError) { - const statusMsgs = (item.statusMessages || []).flatMap(m => m.messages || [m.title]).filter(Boolean); - setPipelineStuck(pItem.key, statusMsgs.join(' ') || 'Download issue — check Sonarr for details'); - if (pItem.logId) addLogStep(pItem.logId, `Queue warning: ${statusMsgs.join(' ')}`, 'warning'); - } - } - } - } - - const pipelineItems = getPipeline(); - for (const pItem of pipelineItems) { - if (pItem.service !== 'sonarr' || pItem.stage !== 'downloading' || !pItem.queueId) continue; - const stillInQueue = (data.records || []).some(r => r.id === pItem.queueId); - if (!stillInQueue) { - // Queue disappearance is the earliest reliable handoff from downloading to import. - advancePipeline(pItem.key, 'importing'); - if (pItem.logId) addLogStep(pItem.logId, 'Download complete — importing to library', 'success'); - scheduleLibraryRefresh(pItem.service + ' queue cleared for ' + (pItem.title || pItem.key), LIBRARY_REFRESH_DELAY_MS); - setTimeout(() => { - completePipeline(pItem.key); - scheduleLibraryRefresh(pItem.service + ' pipeline completed for ' + (pItem.title || pItem.key), 0); - }, 3 * 60 * 1000); - } - } - } catch (err) { - logServerEvent('error', 'pipeline.queue_monitor.failed', { - service: 'sonarr', - error: summarizeError(err), - }); - } - })()); - } - - if (RADARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${RADARR_HOST}/api/v3/queue?page=1&pageSize=100&includeMovie=true&apikey=${RADARR_API_KEY}`, 8000); - const seenRadarr = new Set(); - for (const item of (data.records || [])) { - const dlid = item.downloadId || `__id_${item.id}`; - const isFirstForDlid = !seenRadarr.has(dlid); - if (isFirstForDlid) seenRadarr.add(dlid); - const key = `radarr-q-${dlid}`; - const title = item.movie?.title || 'Unknown'; - const hasError = item.status === 'warning' || item.status === 'error' || item.trackedDownloadStatus === 'warning' || item.trackedDownloadStatus === 'error' || (item.statusMessages?.length > 0); - const msgs = (item.statusMessages || []).map(m => m.title || m.messages?.join(', ')).filter(Boolean); - const errorDetail = item.errorMessage || msgs.join('; ') || ''; - - if (isFirstForDlid && hasError && !queueAlerts.has(key)) { - const logId = logActivity('queue', `${title}: ${errorDetail || 'download issue'}`, { service: 'radarr', queueId: item.id, downloadId: item.downloadId }, 'error', { service: 'radarr', title }); - queueAlerts.set(key, { logId, status: 'error' }); - } else if (isFirstForDlid && !hasError && queueAlerts.has(key)) { - const alert = queueAlerts.get(key); - updateLogEntry(alert.logId, { status: 'success', message: `${title}: resolved` }); - queueAlerts.delete(key); - } - - const queueMovieId = item.movieId || item.movie?.id; - const pipelineItems = getPipeline(); - for (const pItem of pipelineItems) { - if (pItem.service !== 'radarr' || !pItem.movieId || pItem.movieId !== queueMovieId) continue; - if (pItem.stage === 'grabbed' || pItem.stage === 'searching' || pItem.stage === 'stuck') { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : 0; - advancePipeline(pItem.key, 'downloading', { queueId: item.id, progress }); - if (pItem.logId) addLogStep(pItem.logId, 'Release grabbed — now downloading', 'success'); - } else if (pItem.stage === 'downloading' && pItem.queueId === item.id) { - const progress = (item.size > 0 && item.sizeleft != null) - ? Math.round((1 - item.sizeleft / item.size) * 100) : pItem.progress; - const updated = getPipelineItem(pItem.key); - if (updated) updated.progress = progress; - if (hasError) { - const statusMsgs = (item.statusMessages || []).flatMap(m => m.messages || [m.title]).filter(Boolean); - setPipelineStuck(pItem.key, statusMsgs.join(' ') || 'Download issue — check Radarr for details'); - if (pItem.logId) addLogStep(pItem.logId, `Queue warning: ${statusMsgs.join(' ')}`, 'warning'); - } - } - } - } - - const pipelineItems = getPipeline(); - for (const pItem of pipelineItems) { - if (pItem.service !== 'radarr' || pItem.stage !== 'downloading' || !pItem.queueId) continue; - const stillInQueue = (data.records || []).some(r => r.id === pItem.queueId); - if (!stillInQueue) { - advancePipeline(pItem.key, 'importing'); - if (pItem.logId) addLogStep(pItem.logId, 'Download complete — importing to library', 'success'); - scheduleLibraryRefresh(pItem.service + ' queue cleared for ' + (pItem.title || pItem.key), LIBRARY_REFRESH_DELAY_MS); - setTimeout(() => { - completePipeline(pItem.key); - scheduleLibraryRefresh(pItem.service + ' pipeline completed for ' + (pItem.title || pItem.key), 0); - }, 3 * 60 * 1000); - } - } - } catch (err) { - logServerEvent('error', 'pipeline.queue_monitor.failed', { - service: 'radarr', - error: summarizeError(err), - }); - } - })()); - } - - await Promise.allSettled(tasks); -} - -registerInterval(setInterval(monitorQueues, TIMING.MONITOR_QUEUES_INTERVAL_MS || 15000)); - -// ─── Routes ───────────────────────────────────────────────────────────────── - -router.get('/pipeline', (req, res) => { - const items = getPipeline(); - res.json(items.map(item => ({ - key: item.key, - service: item.service, - title: item.title, - subtitle: item.subtitle, - posterUrl: item.posterUrl, - stage: item.stage, - stageStartedAt: item.stageStartedAt || item.stageChangedAt || item.startedAt || item.createdAt, - startedAt: item.startedAt || item.createdAt, - stuckReason: item.stuckReason || item.error, - stuckAt: item.stuckAt, - canRetry: item.canRetry || false, - progress: item.progress, - speed: item.speed, - eta: item.eta, - logId: item.logId, - steps: item.steps || [], - statusDetail: item.statusDetail || null, - statusUpdatedAt: item.statusUpdatedAt || null, - seriesId: item.seriesId || null, - movieId: item.movieId || null, - artistId: item.artistId || null, - seasonNumbers: item.seasonNumbers || null, - queueId: item.queueId || null, - retryId: item.retryId || null, - }))); -}); - -router.delete('/pipeline/:key', (req, res) => { - removePipelineItem(req.params.key); - res.json({ success: true }); -}); - -router.post('/pipeline/:key/retry', async (req, res) => { - const item = getPipelineItem(req.params.key); - if (!item) return res.status(404).json({ error: 'Pipeline item not found' }); - try { - if (item.service === 'sonarr' && item.retryId && SONARR_API_KEY) { - const retrySeasonNumbers = item.retrySeasonNumbers || item.seasonNumbers || null; - let cmdBody = { name: 'SeriesSearch', seriesId: item.retryId }; - let retrySeasonNumber = null; - if (retrySeasonNumbers?.length) { - const searchPlan = await prepareSonarrSearch(item.retryId, retrySeasonNumbers); - const plannedSeasonSearch = searchPlan.seasonSearches[0]; - cmdBody = plannedSeasonSearch?.cmdBody || { name: 'SeasonSearch', seriesId: item.retryId, seasonNumber: retrySeasonNumbers[0] }; - retrySeasonNumber = plannedSeasonSearch?.seasonNumber ?? retrySeasonNumbers[0] ?? null; - if (item.logId) { - if (searchPlan.seriesMonitoringChanged || searchPlan.episodeMonitoringChanged) { - addLogStep(item.logId, `Re-enabled Sonarr monitoring before retrying season ${retrySeasonNumber}`, 'info'); - } - if (plannedSeasonSearch?.mode === 'episode') { - addLogStep(item.logId, `Retrying released episodes individually for season ${retrySeasonNumber}`, 'info'); - } - } - } - const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(cmdBody), - }); - if (!cmdResp.ok) { - throw new Error(await readUpstreamErrorMessage(cmdResp, `Sonarr command failed: HTTP ${cmdResp.status}`)); - } - const cmdData = await cmdResp.json(); - advancePipeline(item.key, 'searching'); - addPipelineStep(item.key, 'Retry submitted — waiting for Sonarr search completion'); - if (item.logId) addLogStep(item.logId, 'Retrying search…', 'info'); - watchSonarrSearch(item.key, cmdData.id, item.retryId, retrySeasonNumber, item.logId, item.title) - .catch((error) => handleWatcherCrash(item.key, 'sonarr-search-retry', error, { - service: 'sonarr', - title: item.title, - retryId: item.retryId, - })); - } else if (item.service === 'radarr' && item.retryId && RADARR_API_KEY) { - advancePipeline(item.key, 'searching'); - pendingSearches.set(item.key, { key: item.key, title: item.title, service: 'radarr', type: 'movie', startedAt: Date.now(), status: 'searching', posterUrl: item.posterUrl || null }); - addPipelineStep(item.key, 'Retry checking Radarr release results directly…'); - if (item.logId) addLogStep(item.logId, 'Retrying direct release check…', 'info'); - - try { - const releases = await fetchWithTimeout(`${RADARR_HOST}/api/v3/release?movieId=${item.retryId}&apikey=${RADARR_API_KEY}`, DIRECT_RELEASE_TIMEOUT_MS); - const releaseList = Array.isArray(releases) ? releases : []; - const approvedRelease = selectApprovedRelease(releaseList); - const approvedCount = releaseList.filter(r => !r.rejected).length; - addPipelineStep(item.key, `Radarr direct retry returned ${releaseList.length} release(s): ${approvedCount} auto-approved`); - - if (approvedRelease) { - addPipelineStep(item.key, `Auto-grabbing ${releaseQualityName(approvedRelease)} with ${approvedRelease.seeders || 0} seeders: ${releaseShortTitle(approvedRelease)}`); - await arrGrab({ - service: 'Radarr', - host: RADARR_HOST, - apiKey: RADARR_API_KEY, - guid: approvedRelease.guid, - indexerId: approvedRelease.indexerId, - title: approvedRelease.title, - }); - const entry = pendingSearches.get(item.key); - if (entry) Object.assign(entry, { status: 'grabbed' }); - advancePipeline(item.key, 'grabbed'); - addPipelineStep(item.key, 'Direct retry grab accepted by Radarr — waiting for qBittorrent handoff'); - if (item.logId) addLogStep(item.logId, `Direct retry grab accepted: ${releaseShortTitle(approvedRelease)}`, 'success'); - setTimeout(() => monitorQueues().catch((error) => { - logServerEvent('warn', 'pipeline.queue_monitor.after_direct_retry_failed', { - ...readPipelineContext(item.key), - error: summarizeError(error), - }); - }), 1500); - setTimeout(() => pendingSearches.delete(item.key), 90000); - return res.json({ success: true, accelerated: true, grabbed: true }); - } - - if (releaseList.length > 0) { - const rejSummary = summarizeReleaseRejections(releaseList); - const noResultsMsg = rejSummary - ? `No grab — ${rejSummary}` - : 'No matching releases found — check indexers or availability'; - markPipelineNoResults(item.key, noResultsMsg, item.logId); - setTimeout(() => pendingSearches.delete(item.key), 120000); - return res.json({ success: true, accelerated: true, grabbed: false, noResults: true }); - } - - addPipelineStep(item.key, 'Radarr direct retry returned no releases — starting full Radarr command search…'); - } catch (directErr) { - addPipelineStep(item.key, `Direct retry did not finish: ${safeErrorMessage(directErr)}. Starting full Radarr command search…`); - logServerEvent('warn', 'pipeline.radarr_direct_retry.failed', { - ...readPipelineContext(item.key), - error: summarizeError(directErr), - }); - } - - const cmdResp = await fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'MoviesSearch', movieIds: [item.retryId] }), - }); - if (!cmdResp.ok) { - throw new Error(await readUpstreamErrorMessage(cmdResp, `Radarr command failed: HTTP ${cmdResp.status}`)); - } - const cmdData = await cmdResp.json(); - addPipelineStep(item.key, 'Retry submitted — waiting for Radarr search completion'); - if (item.logId) addLogStep(item.logId, 'Retrying full Radarr search…', 'info'); - watchRadarrSearch(item.key, cmdData.id, item.retryId, item.logId, item.title) - .catch((error) => handleWatcherCrash(item.key, 'radarr-search-retry', error, { - service: 'radarr', - title: item.title, - retryId: item.retryId, - })); - } else { - return res.status(400).json({ error: 'Cannot retry this item type' }); - } - res.json({ success: true }); - } catch (err) { - if (item.logId) addLogStep(item.logId, `Retry failed: ${err.message}`, 'error'); - logServerEvent('error', 'pipeline.retry.failed', { - ...readPipelineContext(item.key, { - service: item.service, - title: item.title, - retryId: item.retryId, - }), - error: summarizeError(err), - }); - res.status(500).json({ error: err.message }); - } -}); - -router.delete('/pipeline/:key/cancel', async (req, res) => { - const item = getPipelineItem(req.params.key); - if (!item) return res.status(404).json({ error: 'Not found' }); - const warnings = []; - try { - if (item.torrentHash) { - try { - const { qbHost, cookie } = await qbittorrentLogin(); - const resp = await fetch(`${qbHost}/api/v2/torrents/delete`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie }, - body: `hashes=${item.torrentHash}&deleteFiles=true`, - }); - if (!resp.ok) { - const warning = `qBittorrent delete failed: HTTP ${resp.status}`; - warnings.push(warning); - logServerEvent('warn', 'pipeline.cancel.qb_delete_failed', { - ...readPipelineContext(item.key), - status: resp.status, - }); - } - } catch (error) { - const warning = `qBittorrent delete failed: ${summarizeError(error).message}`; - warnings.push(warning); - logServerEvent('warn', 'pipeline.cancel.qb_delete_failed', { - ...readPipelineContext(item.key), - error: summarizeError(error), - }); - } - } - if (item.queueId) { - try { - let resp = null; - if (item.service === 'sonarr' && SONARR_API_KEY) { - resp = await fetch(`${SONARR_HOST}/api/v3/queue/${item.queueId}?removeFromClient=true&blocklist=false&apikey=${SONARR_API_KEY}`, { method: 'DELETE' }); - } else if (item.service === 'radarr' && RADARR_API_KEY) { - resp = await fetch(`${RADARR_HOST}/api/v3/queue/${item.queueId}?removeFromClient=true&blocklist=false&apikey=${RADARR_API_KEY}`, { method: 'DELETE' }); - } - if (resp && !resp.ok) { - const warning = `${item.service} queue cleanup failed: HTTP ${resp.status}`; - warnings.push(warning); - logServerEvent('warn', 'pipeline.cancel.queue_delete_failed', { - ...readPipelineContext(item.key), - status: resp.status, - }); - } - } catch (error) { - const warning = `${item.service} queue cleanup failed: ${summarizeError(error).message}`; - warnings.push(warning); - logServerEvent('warn', 'pipeline.cancel.queue_delete_failed', { - ...readPipelineContext(item.key), - error: summarizeError(error), - }); - } - } - removePipelineItem(req.params.key); - if (warnings.length > 0) { - logServerEvent('warn', 'pipeline.cancel.partial_cleanup', { - ...readPipelineContext(item.key, { - stage: item.stage, - }), - warnings, - }); - } - res.json({ success: true, warnings }); - } catch (err) { - logServerEvent('error', 'pipeline.cancel.failed', { - ...readPipelineContext(item.key), - error: summarizeError(err), - }); - res.status(500).json({ error: err.message }); - } -}); - -router.post('/pipeline/:key/monitor', (req, res) => { - const item = getPipelineItem(req.params.key); - if (!item) return res.status(404).json({ error: 'Not found' }); - if (item.logId) addLogStep(item.logId, 'Set to monitor — will grab automatically when released', 'info'); - removePipelineItem(req.params.key); - res.json({ success: true }); -}); - -router.post('/command/search', async (req, res) => { - const { service, id, seasonNumbers, albumIds } = req.body; - if (!service) return res.status(400).json({ error: 'Missing service' }); - if (!id && service !== 'lidarr') return res.status(400).json({ error: 'Missing id' }); - - let activeLogId = null; - let activePipelineKey = null; - let activeTitle = null; - try { - if (service === 'sonarr' && SONARR_API_KEY) { - const searchPlan = await prepareSonarrSearch(id, seasonNumbers || null); - const { seriesData, seriesMonitoringChanged, episodeMonitoringChanged } = searchPlan; - const seriesTitle = seriesData.title || 'Unknown'; - const seriesPosterUrl = pickArrImageUrl(seriesData.images || [], 'poster', 'sonarr'); - activeTitle = seriesTitle; - - if (seasonNumbers?.length) { - for (const seasonSearch of searchPlan.seasonSearches) { - const sn = seasonSearch.seasonNumber; - const snLabel = seasonNumbers.length > 1 ? `${seriesTitle} S${sn}` : `${seriesTitle} Season ${sn}`; - const logId = logActivity('download', `Searching: ${snLabel}`, { seriesId: id, season: sn }, 'pending', { service: 'sonarr', seriesId: id, season: sn, title: seriesTitle }); - activeLogId = logId; - if (seriesMonitoringChanged || episodeMonitoringChanged) { - addLogStep(logId, `Auto-enabled monitoring for ${snLabel}`, 'info'); - } - if (seasonSearch.mode === 'episode') { - addLogStep(logId, `Searching ${seasonSearch.missingReleasedEpisodeIds.length} released episode(s) individually for ${snLabel}`, 'info'); - } - const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(seasonSearch.cmdBody), - }); - if (!cmdResp.ok) throw new Error(await readUpstreamErrorMessage(cmdResp, `Sonarr command failed: HTTP ${cmdResp.status}`)); - const cmdData = await cmdResp.json(); - updateLogEntry(logId, { status: 'info', message: `Sonarr searching: ${snLabel}` }); - const pendingKey = `sonarr-${id}-${sn}-${Date.now()}`; - activePipelineKey = pendingKey; - pendingSearches.set(pendingKey, { key: pendingKey, service: 'sonarr', title: seriesTitle, subtitle: `Season ${sn}`, seasons: [sn], logId, seriesId: id, posterUrl: seriesPosterUrl, startedAt: Date.now(), status: 'searching' }); - addPipelineItem(pendingKey, { service: 'sonarr', title: seriesTitle, subtitle: `Season ${sn}`, posterUrl: seriesPosterUrl, logId, seriesId: id, seasonNumbers: [sn], retryId: id }); - advancePipeline(pendingKey, 'searching'); - watchSonarrSearch(pendingKey, cmdData.id, id, sn, logId, seriesTitle).catch((error) => handleWatcherCrash(pendingKey, 'sonarr-search', error, { - service: 'sonarr', - title: seriesTitle, - seriesId: id, - seasonNumber: sn, - })); - } - } else { - const logId = logActivity('download', `Searching: ${seriesTitle}`, { seriesId: id }, 'pending', { service: 'sonarr', seriesId: id, title: seriesTitle }); - activeLogId = logId; - if (seriesMonitoringChanged || episodeMonitoringChanged) addLogStep(logId, `Auto-enabled monitoring for ${seriesTitle}`, 'info'); - const cmdResp = await fetch(`${SONARR_HOST}/api/v3/command?apikey=${SONARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }), - }); - if (!cmdResp.ok) throw new Error(await readUpstreamErrorMessage(cmdResp, `Sonarr command failed: HTTP ${cmdResp.status}`)); - const cmdData = await cmdResp.json(); - updateLogEntry(logId, { status: 'info', message: `Sonarr searching: ${seriesTitle}` }); - const pendingKey = `sonarr-${id}-all-${Date.now()}`; - activePipelineKey = pendingKey; - pendingSearches.set(pendingKey, { key: pendingKey, service: 'sonarr', title: seriesTitle, subtitle: 'All seasons', seasons: null, logId, seriesId: id, posterUrl: seriesPosterUrl, startedAt: Date.now(), status: 'searching' }); - addPipelineItem(pendingKey, { service: 'sonarr', title: seriesTitle, subtitle: 'All seasons', posterUrl: seriesPosterUrl, logId, seriesId: id, seasonNumbers: null, retryId: id }); - advancePipeline(pendingKey, 'searching'); - watchSonarrSearch(pendingKey, cmdData.id, id, null, logId, seriesTitle).catch((error) => handleWatcherCrash(pendingKey, 'sonarr-search', error, { - service: 'sonarr', - title: seriesTitle, - seriesId: id, - })); - } - res.json({ success: true }); - - } else if (service === 'radarr' && RADARR_API_KEY) { - let movieTitle = 'Unknown'; - let moviePosterUrl = null; - try { - const movieResp = await fetch(`${RADARR_HOST}/api/v3/movie/${id}?apikey=${RADARR_API_KEY}`); - if (movieResp.ok) { - const movieData = await movieResp.json(); - movieTitle = movieData.title || 'Unknown'; - moviePosterUrl = pickArrImageUrl(movieData.images || [], 'poster', 'radarr'); - } - } catch {} - activeTitle = movieTitle; - const logId = logActivity('download', `Searching for "${movieTitle}"`, { movieId: id }, 'pending', { service: 'radarr', title: movieTitle, movieId: id }); - activeLogId = logId; - const pipelineKey = `radarr-${id}-${Date.now()}`; - activePipelineKey = pipelineKey; - addPipelineItem(pipelineKey, { service: 'radarr', title: movieTitle, subtitle: 'Movie', posterUrl: moviePosterUrl, logId, movieId: id, retryId: id }); - advancePipeline(pipelineKey, 'searching'); - pendingSearches.set(pipelineKey, { key: pipelineKey, title: movieTitle, service: 'radarr', type: 'movie', startedAt: Date.now(), status: 'searching', posterUrl: moviePosterUrl }); - updateLogEntry(logId, { status: 'info', message: `Radarr checking direct release results for "${movieTitle}"` }); - addPipelineStep(pipelineKey, 'Checking Radarr release results directly before starting the slower command search…'); - - try { - const releases = await fetchWithTimeout(`${RADARR_HOST}/api/v3/release?movieId=${id}&apikey=${RADARR_API_KEY}`, DIRECT_RELEASE_TIMEOUT_MS); - const releaseList = Array.isArray(releases) ? releases : []; - const approvedRelease = selectApprovedRelease(releaseList); - const approvedCount = releaseList.filter(r => !r.rejected).length; - addPipelineStep(pipelineKey, `Radarr direct search returned ${releaseList.length} release(s): ${approvedCount} auto-approved`); - - if (approvedRelease) { - addPipelineStep(pipelineKey, `Auto-grabbing ${releaseQualityName(approvedRelease)} with ${approvedRelease.seeders || 0} seeders: ${releaseShortTitle(approvedRelease)}`); - await arrGrab({ - service: 'Radarr', - host: RADARR_HOST, - apiKey: RADARR_API_KEY, - guid: approvedRelease.guid, - indexerId: approvedRelease.indexerId, - title: approvedRelease.title, - }); - const entry = pendingSearches.get(pipelineKey); - if (entry) Object.assign(entry, { status: 'grabbed' }); - advancePipeline(pipelineKey, 'grabbed'); - addPipelineStep(pipelineKey, 'Direct grab accepted by Radarr — waiting for qBittorrent handoff'); - addLogStep(logId, `Direct grab accepted: ${releaseShortTitle(approvedRelease)}`, 'success'); - setTimeout(() => monitorQueues().catch((error) => { - logServerEvent('warn', 'pipeline.queue_monitor.after_direct_grab_failed', { - ...readPipelineContext(pipelineKey), - error: summarizeError(error), - }); - }), 1500); - setTimeout(() => pendingSearches.delete(pipelineKey), 90000); - return res.json({ success: true, accelerated: true, grabbed: true, pipelineKey }); - } - - if (releaseList.length > 0) { - const rejSummary = summarizeReleaseRejections(releaseList); - const noResultsMsg = rejSummary - ? `No grab — ${rejSummary}` - : 'No matching releases found — check indexers or availability'; - markPipelineNoResults(pipelineKey, noResultsMsg, logId); - setTimeout(() => pendingSearches.delete(pipelineKey), 120000); - return res.json({ success: true, accelerated: true, grabbed: false, noResults: true, pipelineKey }); - } - - addPipelineStep(pipelineKey, 'Radarr direct search returned no releases — starting full Radarr command search…'); - } catch (directErr) { - addPipelineStep(pipelineKey, `Direct release check did not finish: ${safeErrorMessage(directErr)}. Starting full Radarr command search…`); - logServerEvent('warn', 'pipeline.radarr_direct_search.failed', { - ...readPipelineContext(pipelineKey), - error: summarizeError(directErr), - }); - } - - const resp = await fetch(`${RADARR_HOST}/api/v3/command?apikey=${RADARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'MoviesSearch', movieIds: [id] }), - }); - if (!resp.ok) throw new Error(await readUpstreamErrorMessage(resp, `Radarr command failed: HTTP ${resp.status}`)); - const cmdData = await resp.json(); - updateLogEntry(logId, { status: 'info', message: `Radarr searching for "${movieTitle}"` }); - watchRadarrSearch(pipelineKey, cmdData.id, id, logId, movieTitle).catch((error) => handleWatcherCrash(pipelineKey, 'radarr-search', error, { - service: 'radarr', - title: movieTitle, - movieId: id, - })); - res.json({ success: true, accelerated: false, pipelineKey }); - - } else if (service === 'lidarr' && LIDARR_API_KEY) { - if (albumIds?.length) { - let albumNames = []; - try { - const albumDetails = await Promise.all(albumIds.map(aid => fetchWithTimeout(`${LIDARR_HOST}/api/v1/album/${aid}?apikey=${LIDARR_API_KEY}`))); - albumNames = albumDetails.map(a => a.title || 'Unknown'); - } catch {} - const logId = logActivity('download', `Searching Lidarr for ${albumIds.length} album(s): ${albumNames.join(', ') || albumIds.join(', ')}`, { albumIds, albumNames }, 'pending', { service: 'lidarr', albumNames, albumIds }); - activeLogId = logId; - activeTitle = albumNames.join(', ') || `${albumIds.length} album(s)`; - const resp = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'AlbumSearch', albumIds }), - }); - if (!resp.ok) throw new Error(await readUpstreamErrorMessage(resp, `Lidarr command failed: HTTP ${resp.status}`)); - updateLogEntry(logId, { status: 'success', message: `Lidarr searching for ${albumIds.length} album(s): ${albumNames.join(', ') || 'unknown'}` }); - } else { - const logId = logActivity('download', 'Searching Lidarr for artist', { artistId: id }, 'pending'); - activeLogId = logId; - const resp = await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'ArtistSearch', artistId: id }), - }); - if (!resp.ok) throw new Error(await readUpstreamErrorMessage(resp, `Lidarr command failed: HTTP ${resp.status}`)); - updateLogEntry(logId, { status: 'success', message: 'Lidarr artist search triggered' }); - } - res.json({ success: true }); - - } else { - return res.status(400).json({ error: 'Unknown service or not configured' }); - } - } catch (err) { - if (activeLogId) { - updateLogEntry(activeLogId, { status: 'error', message: `Search command failed: ${err.message}` }); - } - if (activePipelineKey) { - markPipelineFailure(activePipelineKey, { - event: 'pipeline.command.search_failed', - message: `Search command failed: ${err.message}`, - error: err, - logId: activeLogId, - context: { - service, - title: activeTitle, - requestId: id, - }, - }); - } else { - logServerEvent('error', 'pipeline.command.search_failed', { - service, - requestId: id, - title: activeTitle, - error: summarizeError(err), - }); - } - logActivity('error', `Search command failed: ${err.message}`, { service, id }, 'error'); - res.status(500).json({ error: err.message }); - } -}); - -router.post('/grab', async (req, res) => { - const { service, guid, indexerId, pipelineKey, downloadUrl, title } = req.body; - if (!guid && !downloadUrl) return res.status(400).json({ error: 'Missing guid or downloadUrl' }); - if (downloadUrl && !title) return res.status(400).json({ error: 'Missing title for downloadUrl grab' }); - const cfg = service === 'radarr' && RADARR_API_KEY - ? { service: 'Radarr', host: RADARR_HOST, apiKey: RADARR_API_KEY } - : service === 'sonarr' && SONARR_API_KEY - ? { service: 'Sonarr', host: SONARR_HOST, apiKey: SONARR_API_KEY } - : null; - if (!cfg) return res.status(400).json({ error: 'Unknown service' }); - try { - await arrGrab({ ...cfg, guid, indexerId, downloadUrl, title }); - scheduleLibraryRefresh('arr grab accepted for ' + service, LIBRARY_REFRESH_DELAY_MS); - let pipelineTracked = null; - if (pipelineKey) { - const item = getPipelineItem(pipelineKey); - pipelineTracked = Boolean(item); - if (item) { - advancePipeline(pipelineKey, 'grabbed'); - addPipelineStep(pipelineKey, 'Manual grab accepted by Arr — waiting for download client handoff'); - if (item.logId) addLogStep(item.logId, 'Manual grab accepted by Arr', 'success'); - } else { - logServerEvent('warn', 'pipeline.grab.transition_missing', { - pipelineKey, - service, - title: title || null, - }); - } - } - res.json({ success: true, pipelineTracked }); - } catch (err) { - if (pipelineKey && getPipelineItem(pipelineKey)) { - markPipelineFailure(pipelineKey, { - event: 'pipeline.grab.failed', - message: `Grab failed: ${err.message}`, - error: err, - context: { - service, - title: title || null, - indexerId: indexerId || null, - hasDownloadUrl: Boolean(downloadUrl), - }, - }); - } else { - logServerEvent('error', 'pipeline.grab.failed', { - pipelineKey: pipelineKey || null, - service, - title: title || null, - indexerId: indexerId || null, - hasDownloadUrl: Boolean(downloadUrl), - error: summarizeError(err), - }); - } - res.status(500).json({ error: err.message }); - } -}); - -router.get('/arr-queue', async (req, res) => { - const results = []; - const tasks = []; - if (SONARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${SONARR_HOST}/api/v3/queue?page=1&pageSize=100&includeSeries=true&includeEpisode=true&apikey=${SONARR_API_KEY}`, 8000); - for (const item of (data.records || [])) { - const ep = item.episode ? `S${String(item.episode.seasonNumber).padStart(2,'0')}E${String(item.episode.episodeNumber).padStart(2,'0')}` : null; - const msgs = (item.statusMessages || []).map(m => m.title || (m.messages || []).join(', ')).filter(Boolean); - results.push({ - id: `sonarr-${item.id}`, service: 'sonarr', - seriesId: item.seriesId || item.series?.id || null, - title: item.series?.title || 'Unknown', episode: ep, - seasonNumber: item.episode?.seasonNumber, - status: item.status, trackedStatus: item.trackedDownloadStatus, - progress: item.size > 0 ? Math.round((1 - item.sizeleft / item.size) * 100) : 0, - size: item.size, sizeleft: item.sizeleft, - errorMessage: item.errorMessage || msgs.join('; ') || null, - addedAt: item.added, - posterUrl: pickArrImageUrl(item.series?.images || [], 'poster', 'sonarr'), - }); - } - } catch (err) { - logServerEvent('error', 'pipeline.arr_queue_fetch_failed', { - service: 'sonarr', - error: summarizeError(err), - }); - } - })()); - } - if (RADARR_API_KEY) { - tasks.push((async () => { - try { - const data = await fetchWithTimeout(`${RADARR_HOST}/api/v3/queue?page=1&pageSize=100&includeMovie=true&apikey=${RADARR_API_KEY}`, 8000); - for (const item of (data.records || [])) { - const msgs = (item.statusMessages || []).map(m => m.title || (m.messages || []).join(', ')).filter(Boolean); - results.push({ - id: `radarr-${item.id}`, service: 'radarr', - movieId: item.movieId || item.movie?.id || null, - title: item.movie?.title || 'Unknown', episode: null, seasonNumber: null, - status: item.status, trackedStatus: item.trackedDownloadStatus, - progress: item.size > 0 ? Math.round((1 - item.sizeleft / item.size) * 100) : 0, - size: item.size, sizeleft: item.sizeleft, - errorMessage: item.errorMessage || msgs.join('; ') || null, - addedAt: item.added, - posterUrl: pickArrImageUrl(item.movie?.images || [], 'poster', 'radarr'), - }); - } - } catch (err) { - logServerEvent('error', 'pipeline.arr_queue_fetch_failed', { - service: 'radarr', - error: summarizeError(err), - }); - } - })()); - } - await Promise.allSettled(tasks); - res.json(results); -}); - -router.get('/pending-searches', (req, res) => { - res.json([...pendingSearches.values()].map(s => ({ - key: s.key, service: s.service, title: s.title, subtitle: s.subtitle, - seasons: s.seasons, startedAt: s.startedAt, status: s.status, - posterUrl: s.posterUrl, error: s.error || null, - }))); -}); - -export default router; +export { arrGrab, monitorQueues, watchRadarrSearch, watchSonarrSearch, default } from '../pipeline/index.js'; diff --git a/backend/src/routes/qbittorrent.js b/backend/src/routes/qbittorrent.js index 34eb525..2d74e3c 100644 --- a/backend/src/routes/qbittorrent.js +++ b/backend/src/routes/qbittorrent.js @@ -1,254 +1,73 @@ import { Router } from 'express'; -import { fetchWithTimeout, normalizeForMatch, pickImageUrl, qbAction, qbFetchJson } from '../utils.js'; -import { CONFIG } from '../config.js'; -import { - getBwLifetimeState, - getPipeline, - libraryCache, - logServerEvent, - metadataCache, - noteQbSessionTotals, - saveBwLifetime as persistBwLifetime, - resetBwLifetime, - summarizeError, -} from '../state.js'; +import { qbittorrentLogin, qbAction } from '../utils.js'; +import { getBwLifetime, resetBwLifetime, saveBwLifetime, setBwLifetime } from '../state.js'; const router = Router(); let bwSaveCounter = 0; -let bwSavePending = false; -const TORRENT_POSTER_LOOKUP_TTL_MS = 6 * 60 * 60 * 1000; -const TORRENT_POSTER_LOOKUP_TIMEOUT_MS = 3500; -const torrentPosterLookupCache = new Map(); -const pollFailures = { - bandwidth: false, - status: false, -}; - -function summarizeHash(hash) { - return typeof hash === 'string' && hash.length > 8 ? `${hash.slice(0, 8)}…` : hash || null; -} - -function inferServiceFromCategory(category) { - const value = String(category || '').toLowerCase(); - if (value.includes('radarr') || value.includes('movie')) return 'radarr'; - if (value.includes('sonarr') || value.includes('tv')) return 'sonarr'; - if (value.includes('lidarr') || value.includes('music')) return 'lidarr'; - return null; -} - -function titleMatchesTorrent(title, torrentName) { - const titleText = normalizeForMatch(title); - const torrentText = normalizeForMatch(torrentName); - if (titleText.length < 3) return false; - if (torrentText.includes(titleText)) return true; - const tokens = titleText - .split(' ') - .filter((token) => token.length >= 3 && !['and', 'the', 'for', 'with'].includes(token)); - return tokens.length >= 2 && tokens.every((token) => torrentText.includes(token)); -} - -function pickPosterFromItems(torrent, items, titleKeys = ['title']) { - for (const item of items || []) { - if (!item?.posterUrl) continue; - if (titleKeys.some((key) => titleMatchesTorrent(item?.[key], torrent.name))) { - return item.posterUrl; - } - } - return null; -} - -function compactSpaces(value) { - return String(value || '').replace(/\s+/g, ' ').trim(); -} - -function cleanReleaseTitle(name) { - return compactSpaces(String(name || '') - .split('/') - .pop() - .replace(/\.[a-z0-9]{2,4}$/i, '') - .replace(/\[[^\]]+\]/g, ' ') - .replace(/[._-]+/g, ' ') - .replace(/\b(480p|576p|720p|1080p|2160p|4k|uhd|hdr|dv|web|webrip|webdl|web dl|bluray|blu ray|brrip|bdrip|remux|hdtv|amzn|nf|hulu|aac|ddp|dts|x264|x265|h264|h265|hevc|avc|proper|repack|internal|multi|yts|mx|bitsearch|to|leak|mp4|mkv)\b/gi, ' ')); -} - -function getPosterLookupTerms(name) { - const cleaned = cleanReleaseTitle(name); - const terms = [ - cleaned.match(/^(.+?)\s+s\d{1,2}(?:e\d{1,2})?\b/i)?.[1], - cleaned.match(/^(.+?)\s+(?:19|20)\d{2}\b/)?.[1], - cleaned, - ] - .map(compactSpaces) - .filter((term) => term.length >= 3); - return [...new Set(terms)]; -} - -async function lookupArrPoster(service, term) { - if (service === 'movie' && CONFIG.RADARR_API_KEY) { - const results = await fetchWithTimeout( - `${CONFIG.RADARR_HOST}/api/v3/movie/lookup?term=${encodeURIComponent(term)}&apikey=${CONFIG.RADARR_API_KEY}`, - TORRENT_POSTER_LOOKUP_TIMEOUT_MS, - ); - return Array.isArray(results) ? pickImageUrl(results[0]?.images, 'poster') : null; - } - if (service === 'series' && CONFIG.SONARR_API_KEY) { - const results = await fetchWithTimeout( - `${CONFIG.SONARR_HOST}/api/v3/series/lookup?term=${encodeURIComponent(term)}&apikey=${CONFIG.SONARR_API_KEY}`, - TORRENT_POSTER_LOOKUP_TIMEOUT_MS, - ); - return Array.isArray(results) ? pickImageUrl(results[0]?.images, 'poster') : null; - } - return null; -} - -async function lookupPosterForTorrent(torrent, service) { - const terms = getPosterLookupTerms(torrent.name); - if (terms.length === 0) return null; - const cacheKey = `${service || 'any'}:${normalizeForMatch(terms[0])}`; - const cached = torrentPosterLookupCache.get(cacheKey); - if (cached && Date.now() - cached.ts < TORRENT_POSTER_LOOKUP_TTL_MS) { - return cached.posterUrl; - } - if (cached?.inflight) return cached.inflight; - - const serviceOrder = service === 'radarr' - ? ['movie', 'series'] - : service === 'sonarr' - ? ['series', 'movie'] - : ['series', 'movie']; - - const inflight = (async () => { - for (const kind of serviceOrder) { - for (const term of terms) { - try { - const posterUrl = await lookupArrPoster(kind, term); - if (posterUrl) { - torrentPosterLookupCache.set(cacheKey, { ts: Date.now(), posterUrl }); - return posterUrl; - } - } catch { - /* keep trying cheaper alternate terms/services */ - } - } - } - torrentPosterLookupCache.set(cacheKey, { ts: Date.now(), posterUrl: null }); - return null; - })(); - - torrentPosterLookupCache.set(cacheKey, { ts: Date.now(), posterUrl: null, inflight }); - return inflight; -} - -async function pickTorrentPosterUrl(torrent) { - const hash = String(torrent.hash || '').toLowerCase(); - const cached = metadataCache.data?.[hash]?.posterUrl; - if (cached) return cached; - - const service = inferServiceFromCategory(torrent.category); - const pipelinePoster = pickPosterFromItems( - torrent, - getPipeline().filter((item) => !service || item.service === service), - ); - if (pipelinePoster) return pipelinePoster; - - if (service === 'radarr') { - return pickPosterFromItems(torrent, libraryCache.movies, ['title', 'sortTitle']) || lookupPosterForTorrent(torrent, service); - } - if (service === 'sonarr') { - return pickPosterFromItems(torrent, libraryCache.series, ['title', 'sortTitle']) || lookupPosterForTorrent(torrent, service); - } - if (service === 'lidarr') { - return pickPosterFromItems(torrent, libraryCache.artists, ['artistName', 'sortName']); - } - return pickPosterFromItems(torrent, [ - ...libraryCache.movies, - ...libraryCache.series, - ...libraryCache.artists, - ], ['title', 'sortTitle', 'artistName', 'sortName']) || lookupPosterForTorrent(torrent, service); -} - -function markPollFailure(key, event, error, fields = {}) { - if (!pollFailures[key]) { - logServerEvent('error', event, { - ...fields, - error: summarizeError(error), - }); - } - pollFailures[key] = true; -} - -function markPollRecovery(key, event, fields = {}) { - if (pollFailures[key]) { - logServerEvent('info', event, fields); - } - pollFailures[key] = false; -} - -function queueBwLifetimeSave() { - // Overlapping flushes can race under fast polling; the next save writes the latest snapshot. - if (bwSavePending) return; - bwSavePending = true; - try { - persistBwLifetime(); - } finally { - bwSavePending = false; - } -} router.get('/bandwidth', async (req, res) => { try { - const info = await qbFetchJson('/api/v2/transfer/info'); + const { qbHost, cookie } = await qbittorrentLogin(); + const r = await fetch(`${qbHost}/api/v2/transfer/info`, { + headers: { Cookie: cookie, Referer: qbHost }, + }); + if (!r.ok) throw new Error(`qBittorrent transfer/info HTTP ${r.status}`); + const info = await r.json(); const sessionDl = info.dl_info_data || 0; const sessionUl = info.up_info_data || 0; - const before = getBwLifetimeState(); - const after = noteQbSessionTotals(sessionDl, sessionUl); - const rolledOver = - sessionDl < before.lastSession.dl || - sessionUl < before.lastSession.ul; + const bwLifetime = getBwLifetime(); // qBittorrent resets these counters on restart, so roll the previous session into the baseline. - if (rolledOver) { - queueBwLifetimeSave(); - logServerEvent('info', 'state.bandwidth_lifetime.rollover_detected', { - previousSession: before.lastSession, - currentSession: { dl: sessionDl, ul: sessionUl }, - baseline: after.baseline, - }); + if (sessionDl < bwLifetime.lastSession.dl || sessionUl < bwLifetime.lastSession.ul) { + bwLifetime.baseline.dl += bwLifetime.lastSession.dl; + bwLifetime.baseline.ul += bwLifetime.lastSession.ul; + saveBwLifetime(); } + bwLifetime.lastSession.dl = sessionDl; + bwLifetime.lastSession.ul = sessionUl; + setBwLifetime(bwLifetime); // This route is polled often enough that periodic flushes are cheaper than per-request writes. - if (++bwSaveCounter >= 20) { bwSaveCounter = 0; queueBwLifetimeSave(); } - markPollRecovery('bandwidth', 'qb.transfer.status_recovered'); + if (++bwSaveCounter >= 20) { + bwSaveCounter = 0; + saveBwLifetime(); + } res.json({ dlSpeed: info.dl_info_speed || 0, ulSpeed: info.up_info_speed || 0, dlTotal: sessionDl, ulTotal: sessionUl, - lifetimeDl: after.baseline.dl + sessionDl, - lifetimeUl: after.baseline.ul + sessionUl, + lifetimeDl: bwLifetime.baseline.dl + sessionDl, + lifetimeUl: bwLifetime.baseline.ul + sessionUl, }); } catch (e) { - markPollFailure('bandwidth', 'qb.transfer.status_failed', e, { - route: '/api/bandwidth', - }); res.status(500).json({ error: e.message }); } }); router.delete('/bandwidth/lifetime', (req, res) => { resetBwLifetime(); - queueBwLifetimeSave(); - logServerEvent('info', 'state.bandwidth_lifetime.reset', { - route: '/api/bandwidth/lifetime', - }); + saveBwLifetime(); res.json({ ok: true }); }); router.get('/qbittorrent/status', async (req, res) => { try { - const torrents = await qbFetchJson('/api/v2/torrents/info'); - markPollRecovery('status', 'qb.torrents.status_recovered'); + const { qbHost, cookie } = await qbittorrentLogin(); + + const torrentsResponse = await fetch(`${qbHost}/api/v2/torrents/info`, { + headers: { + Cookie: cookie, + Referer: qbHost, + }, + }); + + if (!torrentsResponse.ok) { + throw new Error(`Failed to fetch torrents: HTTP ${torrentsResponse.status}`); + } - const formattedTorrents = await Promise.all(torrents.map(async torrent => ({ + const torrents = await torrentsResponse.json(); + + const formattedTorrents = torrents.map(torrent => ({ name: torrent.name, hash: torrent.hash, size: torrent.size, @@ -259,24 +78,19 @@ router.get('/qbittorrent/status', async (req, res) => { state: torrent.state, ratio: torrent.ratio, category: torrent.category, - posterUrl: await pickTorrentPosterUrl(torrent), addedOn: new Date(torrent.added_on * 1000).toISOString(), - completedOn: torrent.completion_on > 0 - ? new Date(torrent.completion_on * 1000).toISOString() - : null - }))); + completedOn: torrent.completion_on > 0 ? new Date(torrent.completion_on * 1000).toISOString() : null, + })); res.json({ torrents: formattedTorrents, - count: formattedTorrents.length + count: formattedTorrents.length, }); } catch (error) { - markPollFailure('status', 'qb.torrents.status_failed', error, { - route: '/api/qbittorrent/status', - }); + console.error('Error fetching qBittorrent data:', error); res.status(500).json({ error: 'Failed to fetch qBittorrent data', - message: error.message + message: error.message, }); } }); @@ -284,17 +98,8 @@ router.get('/qbittorrent/status', async (req, res) => { router.post('/qbittorrent/torrents/:hash/pause', async (req, res) => { try { await qbAction(req.params.hash, 'pause'); - logServerEvent('info', 'qb.torrent.action_succeeded', { - action: 'pause', - hash: summarizeHash(req.params.hash), - }); res.json({ success: true }); } catch (e) { - logServerEvent('error', 'qb.torrent.action_failed', { - action: 'pause', - hash: summarizeHash(req.params.hash), - error: summarizeError(e), - }); res.status(500).json({ error: e.message }); } }); @@ -302,17 +107,8 @@ router.post('/qbittorrent/torrents/:hash/pause', async (req, res) => { router.post('/qbittorrent/torrents/:hash/resume', async (req, res) => { try { await qbAction(req.params.hash, 'resume'); - logServerEvent('info', 'qb.torrent.action_succeeded', { - action: 'resume', - hash: summarizeHash(req.params.hash), - }); res.json({ success: true }); } catch (e) { - logServerEvent('error', 'qb.torrent.action_failed', { - action: 'resume', - hash: summarizeHash(req.params.hash), - error: summarizeError(e), - }); res.status(500).json({ error: e.message }); } }); @@ -321,19 +117,8 @@ router.delete('/qbittorrent/torrents/:hash', async (req, res) => { const deleteFiles = req.query.deleteFiles === 'true' ? 'true' : 'false'; try { await qbAction(req.params.hash, 'delete', `deleteFiles=${deleteFiles}`); - logServerEvent('info', 'qb.torrent.action_succeeded', { - action: 'delete', - hash: summarizeHash(req.params.hash), - deleteFiles: deleteFiles === 'true', - }); res.json({ success: true }); } catch (e) { - logServerEvent('error', 'qb.torrent.action_failed', { - action: 'delete', - hash: summarizeHash(req.params.hash), - deleteFiles: deleteFiles === 'true', - error: summarizeError(e), - }); res.status(500).json({ error: e.message }); } }); @@ -341,14 +126,15 @@ router.delete('/qbittorrent/torrents/:hash', async (req, res) => { router.get('/qbittorrent/torrents/:hash/detail', async (req, res) => { const { hash } = req.params; try { - const [torrentsResult, propsResult, trackersResult] = await Promise.allSettled([ - qbFetchJson(`/api/v2/torrents/info?hashes=${hash}`), - qbFetchJson(`/api/v2/torrents/properties?hash=${hash}`), - qbFetchJson(`/api/v2/torrents/trackers?hash=${hash}`), + const { qbHost, cookie } = await qbittorrentLogin(); + const [tResp, pResp, trkResp] = await Promise.all([ + fetch(`${qbHost}/api/v2/torrents/info?hashes=${hash}`, { headers: { Cookie: cookie } }), + fetch(`${qbHost}/api/v2/torrents/properties?hash=${hash}`, { headers: { Cookie: cookie } }), + fetch(`${qbHost}/api/v2/torrents/trackers?hash=${hash}`, { headers: { Cookie: cookie } }), ]); - const torrents = torrentsResult.status === 'fulfilled' ? torrentsResult.value : []; - const props = propsResult.status === 'fulfilled' ? propsResult.value : {}; - const trackers = trackersResult.status === 'fulfilled' ? trackersResult.value : []; + const torrents = tResp.ok ? await tResp.json() : []; + const props = pResp.ok ? await pResp.json() : {}; + const trackers = trkResp.ok ? await trkResp.json() : []; const t = torrents[0]; if (!t) return res.status(404).json({ error: 'Torrent not found' }); res.json({ @@ -376,10 +162,6 @@ router.get('/qbittorrent/torrents/:hash/detail', async (req, res) => { createdBy: props.created_by, }); } catch (err) { - logServerEvent('error', 'qb.torrent.detail_failed', { - hash: summarizeHash(hash), - error: summarizeError(err), - }); res.status(500).json({ error: err.message }); } }); diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js index 0ea8d5e..612af18 100644 --- a/backend/src/routes/search.js +++ b/backend/src/routes/search.js @@ -10,44 +10,72 @@ const { PROWLARR_HOST, PROWLARR_API_KEY, RADARR_HOST, RADARR_API_KEY, SONARR_HOS function detectQualityFromTitle(t) { if (!t) return 'Unknown'; const l = t.toLowerCase(); - const res = /2160p|4k(?!\w)/i.test(l) ? '2160p' : /1080p/i.test(l) ? '1080p' : /720p/i.test(l) ? '720p' : /480p/i.test(l) ? '480p' : ''; - const src = /remux/i.test(l) ? 'Remux' : /blu-?ray|bdrip|brrip/i.test(l) ? 'Bluray' : /web-?dl/i.test(l) ? 'WEBDL' : /webrip/i.test(l) ? 'WEBRip' : /hdtv/i.test(l) ? 'HDTV' : /dvdrip|dvdscr/i.test(l) ? 'DVD' : ''; + const res = /2160p|4k(?!\w)/i.test(l) + ? '2160p' + : /1080p/i.test(l) + ? '1080p' + : /720p/i.test(l) + ? '720p' + : /480p/i.test(l) + ? '480p' + : ''; + const src = /remux/i.test(l) + ? 'Remux' + : /blu-?ray|bdrip|brrip/i.test(l) + ? 'Bluray' + : /web-?dl/i.test(l) + ? 'WEBDL' + : /webrip/i.test(l) + ? 'WEBRip' + : /hdtv/i.test(l) + ? 'HDTV' + : /dvdrip|dvdscr/i.test(l) + ? 'DVD' + : ''; return src && res ? `${src}-${res}` : src || res || 'Unknown'; } let prowlarrToSonarrIndexer = {}; let prowlarrToRadarrIndexer = {}; +let indexerMapsUpdatedAt = 0; +const INDEXER_MAP_TTL_MS = 5 * 60 * 1000; + +async function fetchProwlarrIndexerMap(host, apiKey) { + const idxs = await fetchWithTimeout(`${host}/api/v3/indexer?apikey=${apiKey}`, 5000); + const map = {}; + if (!Array.isArray(idxs)) return map; + // Arr only exposes the backing Prowlarr indexer id inside the proxied baseUrl suffix. + for (const idx of idxs) { + const base = idx.fields?.find(f => f.name === 'baseUrl')?.value || ''; + const m = base.match(/\/(\d+)\/$/); + if (m) map[parseInt(m[1])] = idx.id; + } + return map; +} async function buildIndexerMaps() { try { if (CONFIG.SONARR_API_KEY) { - const idxs = await fetchWithTimeout(`${CONFIG.SONARR_HOST}/api/v3/indexer?apikey=${CONFIG.SONARR_API_KEY}`, 5000); - if (Array.isArray(idxs)) { - prowlarrToSonarrIndexer = {}; - for (const idx of idxs) { - const base = idx.fields?.find(f => f.name === 'baseUrl')?.value || ''; - // Arr only exposes the backing Prowlarr indexer id inside the proxied baseUrl suffix. - const m = base.match(/\/(\d+)\/$/); - if (m) prowlarrToSonarrIndexer[parseInt(m[1])] = idx.id; - } - } + prowlarrToSonarrIndexer = await fetchProwlarrIndexerMap(CONFIG.SONARR_HOST, CONFIG.SONARR_API_KEY); } if (CONFIG.RADARR_API_KEY) { - const idxs = await fetchWithTimeout(`${CONFIG.RADARR_HOST}/api/v3/indexer?apikey=${CONFIG.RADARR_API_KEY}`, 5000); - if (Array.isArray(idxs)) { - prowlarrToRadarrIndexer = {}; - for (const idx of idxs) { - const base = idx.fields?.find(f => f.name === 'baseUrl')?.value || ''; - const m = base.match(/\/(\d+)\/$/); - if (m) prowlarrToRadarrIndexer[parseInt(m[1])] = idx.id; - } - } + prowlarrToRadarrIndexer = await fetchProwlarrIndexerMap(CONFIG.RADARR_HOST, CONFIG.RADARR_API_KEY); } + indexerMapsUpdatedAt = Date.now(); } catch (e) { console.warn('buildIndexerMaps error:', e.message); } } +async function ensureIndexerMaps(service) { + const now = Date.now(); + const sonarrMissing = service !== 'radarr' && SONARR_API_KEY && Object.keys(prowlarrToSonarrIndexer).length === 0; + const radarrMissing = service !== 'sonarr' && RADARR_API_KEY && Object.keys(prowlarrToRadarrIndexer).length === 0; + if (sonarrMissing || radarrMissing || now - indexerMapsUpdatedAt > INDEXER_MAP_TTL_MS) { + await buildIndexerMaps(); + } +} + buildIndexerMaps().catch(e => console.warn('buildIndexerMaps startup failed:', e.message)); router.get('/fast-search', async (req, res) => { @@ -57,42 +85,47 @@ router.get('/fast-search', async (req, res) => { const cacheKey = `${query.trim().toLowerCase()}|${service || 'any'}`; const cached = fastSearchCache.get(cacheKey); - if (cached && (Date.now() - cached.ts) < 5 * 60 * 1000) return res.json(cached.results); + if (cached && Date.now() - cached.ts < 5 * 60 * 1000) return res.json(cached.results); for (const [k, v] of fastSearchCache) { if (Date.now() - v.ts >= 5 * 60 * 1000) fastSearchCache.delete(k); } try { + await ensureIndexerMaps(service); const catParam = service === 'sonarr' ? '&categories=5000' : service === 'radarr' ? '&categories=2000' : ''; const prowlarrUrl = `${PROWLARR_HOST}/api/v1/search?query=${encodeURIComponent(query.trim())}&type=search${catParam}&limit=100`; const raw = await fetchWithTimeout(prowlarrUrl, 15000, { 'X-Api-Key': PROWLARR_API_KEY }); if (!Array.isArray(raw)) throw new Error('Unexpected Prowlarr response format'); - const results = raw.flatMap(r => { - const pid = r.indexerId; - const sid = prowlarrToSonarrIndexer[pid] ?? null; - const rid = prowlarrToRadarrIndexer[pid] ?? null; - // Grab requests need the downstream Arr indexer id, not Prowlarr's search id. - const indexerId = service === 'sonarr' ? sid : service === 'radarr' ? rid : (sid ?? rid); - if (indexerId === null) return []; - return [{ - guid: r.guid, - title: r.title, - indexer: r.indexer, - indexerId, - prowlarrIndexerId: pid, - seeders: r.seeders || 0, - leechers: r.leechers || 0, - size: r.size || 0, - quality: detectQualityFromTitle(r.title), - ageHours: r.ageHours || 0, - rejected: false, - rejections: [], - releaseGroup: null, - protocol: r.protocol || 'torrent', - downloadUrl: r.downloadUrl || null, - }]; - }).sort((a, b) => (b.seeders || 0) - (a.seeders || 0)); + const results = raw + .flatMap(r => { + const pid = r.indexerId; + const sid = prowlarrToSonarrIndexer[pid] ?? null; + const rid = prowlarrToRadarrIndexer[pid] ?? null; + // Grab requests need the downstream Arr indexer id, not Prowlarr's search id. + const indexerId = service === 'sonarr' ? sid : service === 'radarr' ? rid : (sid ?? rid); + if (indexerId === null) return []; + return [ + { + guid: r.guid, + title: r.title, + indexer: r.indexer, + indexerId, + prowlarrIndexerId: pid, + seeders: r.seeders || 0, + leechers: r.leechers || 0, + size: r.size || 0, + quality: detectQualityFromTitle(r.title), + ageHours: r.ageHours || 0, + rejected: false, + rejections: [], + releaseGroup: null, + protocol: r.protocol || 'torrent', + downloadUrl: r.downloadUrl || null, + }, + ]; + }) + .sort((a, b) => (b.seeders || 0) - (a.seeders || 0)); fastSearchCache.set(cacheKey, { results, ts: Date.now() }); res.json(results); @@ -110,7 +143,7 @@ router.get('/manual-search', async (req, res) => { if (service === 'radarr' && RADARR_API_KEY) { const data = await fetchWithTimeout( `${RADARR_HOST}/api/v3/release?movieId=${id}&apikey=${RADARR_API_KEY}`, - 90000 + 90000, ); releases = Array.isArray(data) ? data : []; } else if (service === 'sonarr' && SONARR_API_KEY) { @@ -123,21 +156,23 @@ router.get('/manual-search', async (req, res) => { return res.status(400).json({ error: 'Unknown service or not configured' }); } releases.sort((a, b) => (b.seeders || 0) - (a.seeders || 0) || (b.size || 0) - (a.size || 0)); - res.json(releases.map(r => ({ - guid: r.guid, - title: r.title, - indexer: r.indexer, - seeders: r.seeders || 0, - leechers: r.leechers || 0, - size: r.size || 0, - quality: r.quality?.quality?.name || r.qualityVersion || 'Unknown', - ageHours: r.ageHours || 0, - rejected: r.rejected || false, - rejections: r.rejections || [], - indexerId: r.indexerId, - releaseGroup: r.releaseGroup || null, - protocol: r.protocol || 'torrent', - }))); + res.json( + releases.map(r => ({ + guid: r.guid, + title: r.title, + indexer: r.indexer, + seeders: r.seeders || 0, + leechers: r.leechers || 0, + size: r.size || 0, + quality: r.quality?.quality?.name || r.qualityVersion || 'Unknown', + ageHours: r.ageHours || 0, + rejected: r.rejected || false, + rejections: r.rejections || [], + indexerId: r.indexerId, + releaseGroup: r.releaseGroup || null, + protocol: r.protocol || 'torrent', + })), + ); } catch (err) { console.error('Manual search error:', err.message); res.status(500).json({ error: err.message }); diff --git a/backend/src/routes/slskd.js b/backend/src/routes/slskd.js index 4ee3ea3..1450adb 100644 --- a/backend/src/routes/slskd.js +++ b/backend/src/routes/slskd.js @@ -1,9 +1,7 @@ import { Router } from 'express'; import { CONFIG } from '../config.js'; import { fetchWithTimeout } from '../utils.js'; -import { - logActivity, updateLogEntry, addLogStep, getActivityLog, registerInterval, -} from '../state.js'; +import { logActivity, updateLogEntry, addLogStep, getActivityLog, registerInterval } from '../state.js'; const router = Router(); @@ -11,24 +9,17 @@ const { SLSKD_HOST, SLSKD_API_KEY, LIDARR_HOST, LIDARR_API_KEY } = CONFIG; async function slskdFetch(path, options = {}) { const url = `${SLSKD_HOST}${path}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 10000); - try { - const resp = await fetch(url, { - headers: { 'X-API-Key': SLSKD_API_KEY, ...options.headers }, - signal: controller.signal, - ...options, - }); - if (!resp.ok) throw new Error(`SLSKD ${path} HTTP ${resp.status}`); - return resp.json(); - } finally { - clearTimeout(timer); - } + const resp = await fetch(url, { + headers: { 'X-API-Key': SLSKD_API_KEY, ...options.headers }, + ...options, + }); + if (!resp.ok) throw new Error(`SLSKD ${path} HTTP ${resp.status}`); + return resp.json(); } router.get('/slskd/downloads', async (req, res) => { try { - const data = await slskdFetch('/api/v0/transfers/downloads'); + const data = await slskdFetch('/api/v0/downloads'); res.json(Array.isArray(data) ? data : []); } catch (err) { console.error('slskd downloads error:', err.message); @@ -52,7 +43,7 @@ router.post('/slskd/retry', async (req, res) => { try { const { username, filename } = req.body; if (!username || !filename) return res.status(400).json({ error: 'Missing username or filename' }); - await slskdFetch('/api/v0/transfers/downloads/retry', { + await slskdFetch('/api/v0/downloads/retry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, filename }), @@ -68,7 +59,7 @@ router.delete('/slskd/downloads/:username', async (req, res) => { try { const { username } = req.params; if (!username) return res.status(400).json({ error: 'Missing username' }); - await slskdFetch(`/api/v0/transfers/downloads/${encodeURIComponent(username)}`, { method: 'DELETE' }); + await slskdFetch(`/api/v0/downloads/${encodeURIComponent(username)}`, { method: 'DELETE' }); res.json({ success: true }); } catch (err) { console.error('slskd remove user downloads error:', err.message); @@ -80,10 +71,9 @@ router.delete('/slskd/downloads/:username/:fileId', async (req, res) => { try { const { username, fileId } = req.params; if (!username || !fileId) return res.status(400).json({ error: 'Missing username or fileId' }); - await slskdFetch( - `/api/v0/transfers/downloads/${encodeURIComponent(username)}/${encodeURIComponent(fileId)}`, - { method: 'DELETE' } - ); + await slskdFetch(`/api/v0/downloads/${encodeURIComponent(username)}/${encodeURIComponent(fileId)}`, { + method: 'DELETE', + }); res.json({ success: true }); } catch (err) { console.error('slskd remove single download error:', err.message); @@ -103,9 +93,14 @@ export async function searchAndDownloadSlskd(artistName, albumTitle, logId, arti } const best = results[0]; - if (logId) addLogStep(logId, `Best Soulseek result: ${best.filename || 'unknown'} from ${best.username || 'unknown'}`, 'info'); + if (logId) + addLogStep( + logId, + `Best Soulseek result: ${best.filename || 'unknown'} from ${best.username || 'unknown'}`, + 'info', + ); - await slskdFetch('/api/v0/transfers/downloads', { + await slskdFetch('/api/v0/downloads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: best.username, filename: best.filename }), @@ -124,7 +119,7 @@ export async function searchAndDownloadSlskd(artistName, albumTitle, logId, arti export async function processCompletedSlskdDownloads() { if (!SLSKD_API_KEY || !LIDARR_API_KEY) return; try { - const downloads = await slskdFetch('/api/v0/transfers/downloads'); + const downloads = await slskdFetch('/api/v0/downloads'); if (!Array.isArray(downloads)) return; const now = Date.now(); @@ -136,21 +131,17 @@ export async function processCompletedSlskdDownloads() { if (seen.has(dl.filename)) continue; seen.add(dl.filename); - const alreadyLogged = log.some(e => - e.details?.slskdFilename === dl.filename && e.type === 'import' - ); + const alreadyLogged = log.some(e => e.details?.slskdFilename === dl.filename && e.type === 'import'); if (alreadyLogged) continue; - const match = dl.filename?.match( - /(.+?)\s*[-–]\s*(.+?)\s*[\(\[]?\d{4}[\)\]]?/i - ); + const match = dl.filename?.match(/(.+?)\s*[-–]\s*(.+?)\s*[\(\[]?\d{4}[\)\]]?/i); if (match) { + // Soulseek completions lack Lidarr ids; infer artist/album from the folder name. const artistGuess = match[1].trim(); const albumGuess = match[2].trim(); try { - // Soulseek completions lack Lidarr ids; infer artist/album from the folder name. await fetch(`${LIDARR_HOST}/api/v1/command?apikey=${LIDARR_API_KEY}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -164,24 +155,36 @@ export async function processCompletedSlskdDownloads() { console.error('[slskd-completed] Lidarr import trigger failed:', lidarrErr.message); } - logActivity('import', `Imported Soulseek download: ${dl.filename}`, { - slskdFilename: dl.filename, - artist: artistGuess, - album: albumGuess, - completedAt: dl.completedAt || new Date().toISOString(), - }, 'success', { service: 'lidarr', title: `${artistGuess} - ${albumGuess}` }); + logActivity( + 'import', + `Imported Soulseek download: ${dl.filename}`, + { + slskdFilename: dl.filename, + artist: artistGuess, + album: albumGuess, + completedAt: dl.completedAt || new Date().toISOString(), + }, + 'success', + { service: 'lidarr', title: `${artistGuess} - ${albumGuess}` }, + ); } else { - logActivity('import', `Soulseek download completed: ${dl.filename}`, { - slskdFilename: dl.filename, - completedAt: dl.completedAt || new Date().toISOString(), - }, 'success', { service: 'lidarr', title: dl.filename }); + logActivity( + 'import', + `Soulseek download completed: ${dl.filename}`, + { + slskdFilename: dl.filename, + completedAt: dl.completedAt || new Date().toISOString(), + }, + 'success', + { service: 'lidarr', title: dl.filename }, + ); } try { // Older SLSKD deletes by filename; newer builds expose an id. await slskdFetch( - `/api/v0/transfers/downloads/${encodeURIComponent(dl.username)}/${encodeURIComponent(dl.id || dl.filename)}`, - { method: 'DELETE' } + `/api/v0/downloads/${encodeURIComponent(dl.username)}/${encodeURIComponent(dl.id || dl.filename)}`, + { method: 'DELETE' }, ); } catch (cleanupErr) { console.error('[slskd-completed] cleanup failed:', cleanupErr.message); @@ -198,7 +201,7 @@ let lastCompletedSnapshot = []; export async function fetchSlskdDownloads() { if (!SLSKD_API_KEY) return { active: [], downloading: [], completed: [] }; try { - const downloads = await slskdFetch('/api/v0/transfers/downloads'); + const downloads = await slskdFetch('/api/v0/downloads'); if (!Array.isArray(downloads)) return { active: [], downloading: [], completed: [] }; const formatted = downloads.map(dl => ({ @@ -207,8 +210,7 @@ export async function fetchSlskdDownloads() { filename: dl.filename || '', bytes: dl.bytes || 0, state: dl.state || 'Unknown', - progress: dl.bytes != null && dl.size != null && dl.size > 0 - ? Math.round((dl.bytes / dl.size) * 100) : 0, + progress: dl.bytes != null && dl.size != null && dl.size > 0 ? Math.round((dl.bytes / dl.size) * 100) : 0, size: dl.size || 0, direction: dl.direction || 'Download', position: dl.position || 0, @@ -218,7 +220,9 @@ export async function fetchSlskdDownloads() { })); const active = formatted.filter(d => d.state === 'Downloading' || d.state === 'Processing'); - const downloading = formatted.filter(d => d.state === 'Queued' || d.state === 'Pending' || d.state === 'InProgress'); + const downloading = formatted.filter( + d => d.state === 'Queued' || d.state === 'Pending' || d.state === 'InProgress', + ); const completed = formatted.filter(d => d.state === 'Completed' || d.state === 'complete'); lastActiveSnapshot = active; @@ -232,10 +236,16 @@ export async function fetchSlskdDownloads() { } } -const slskdFetchInterval = setInterval(fetchSlskdDownloads, 10000); -registerInterval(slskdFetchInterval); - -const slskdProcessInterval = setInterval(processCompletedSlskdDownloads, 30000); -registerInterval(slskdProcessInterval); +registerInterval( + setInterval(() => { + fetchSlskdDownloads(); + }, 10000), +); + +registerInterval( + setInterval(() => { + processCompletedSlskdDownloads(); + }, 30000), +); export default router; diff --git a/backend/src/state.js b/backend/src/state.js index b23e054..d4b111e 100644 --- a/backend/src/state.js +++ b/backend/src/state.js @@ -5,138 +5,10 @@ import { CONFIG, TIMING } from './config.js'; import { readFileSync, writeFileSync, existsSync } from 'fs'; -const LOG_SECRET_KEYS = ['apikey', 'api_key', 'token', 'password', 'pass', 'cookie', 'sid', 'session']; -const persistenceHealth = { - bandwidthSaveFailed: false, - activitySaveFailed: false, -}; - -function redactQueryValue(match, prefix) { - return `${prefix}[REDACTED]`; -} - -function sanitizeString(value) { - if (typeof value !== 'string') return value; - let sanitized = value - .replace(/([?&](?:apikey|api_key|token|password|pass|cookie|sid|session)=)[^&\s]+/gi, redactQueryValue) - .replace(/((?:apikey|api_key|token|password|pass|cookie|sid|session)=)[^\s&]+/gi, redactQueryValue) - .replace(/\b([a-f0-9]{8})[a-f0-9]{24,56}\b/gi, '$1…'); - - try { - const parsed = new URL(value); - parsed.username = ''; - parsed.password = ''; - for (const key of parsed.searchParams.keys()) { - if (LOG_SECRET_KEYS.includes(key.toLowerCase())) { - parsed.searchParams.set(key, '[REDACTED]'); - } - } - sanitized = parsed.toString(); - } catch { - // Leave non-URL strings in their sanitized text form. - } - - return sanitized.length > 240 ? `${sanitized.slice(0, 237)}...` : sanitized; -} - -function sanitizeLogFields(value) { - if (value == null) return value; - if (typeof value === 'string') return sanitizeString(value); - if (Array.isArray(value)) return value.map((entry) => sanitizeLogFields(entry)); - if (value instanceof Error) return summarizeError(value); - if (typeof value !== 'object') return value; - - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, sanitizeLogFields(entry)]), - ); -} - -export function summarizeError(error) { - if (error instanceof Error) { - return sanitizeLogFields({ - name: error.name, - code: error.code || null, - message: error.message || 'Unknown error', - }); - } - return sanitizeLogFields({ - name: 'Error', - code: null, - message: typeof error === 'string' ? error : JSON.stringify(error), - }); -} - -export function logServerEvent(level, event, fields = {}) { - const payload = sanitizeLogFields({ - ts: new Date().toISOString(), - level, - event, - ...fields, - }); - const line = JSON.stringify(payload); - if (level === 'error') { - console.error(line); - } else if (level === 'warn') { - console.warn(line); - } else { - console.log(line); - } -} - // ─── Activity Log ─────────────────────────────────────────────────────────── const activityLog = []; const MAX_LOG_ENTRIES = 200; -const MAX_PERSISTED_LOG_ENTRIES = 100; - -function getQueueAlertIdentity(entry) { - if (!entry || entry.type !== 'queue') return null; - const service = entry.details?.service || entry.context?.service; - const discriminator = entry.details?.downloadId || entry.details?.queueId || null; - if (!service || !discriminator) return null; - return `${service}:${discriminator}`; -} - -function dedupeQueueErrorEntries(entries) { - const seenQueueErrors = new Set(); - const deduped = []; - for (const entry of entries) { - const identity = entry?.status === 'error' ? getQueueAlertIdentity(entry) : null; - if (identity) { - if (seenQueueErrors.has(identity)) continue; - seenQueueErrors.add(identity); - } - deduped.push(entry); - } - return deduped; -} - -export function isVisibleActivityEntry(entry) { - if (!entry) return false; - // Queue import failures can linger in Sonarr/Radarr for a long time and drown out - // the operator-facing activity feed. Keep them available via includeHidden=true. - if (entry.type === 'queue' && entry.status === 'error') return false; - return true; -} - -export function getActivityFeed({ since = null, limit = 50, includeHidden = false } = {}) { - const cappedLimit = Math.min(parseInt(limit, 10) || 50, 100); - const now = Date.now(); - let entries = dedupeQueueErrorEntries(activityLog); - - if (since) { - const sinceDate = new Date(since); - if (!isNaN(sinceDate.getTime())) { - entries = entries.filter(entry => new Date(entry.timestamp) > sinceDate); - } - } - - if (!includeHidden) { - entries = entries.filter(entry => isVisibleActivityEntry(entry, now)); - } - - return entries.slice(0, cappedLimit); -} export function getActivityLog() { return activityLog; @@ -146,7 +18,10 @@ export function logActivity(type, message, details = null, status = 'info', cont const entry = { id: Date.now() + '-' + Math.random().toString(36).substr(2, 6), timestamp: new Date().toISOString(), - type, message, details, status, + type, + message, + details, + status, context, steps: [{ timestamp: new Date().toISOString(), message, status }], }; @@ -174,44 +49,10 @@ export function addLogStep(id, stepMessage, stepStatus) { entry.steps.push({ timestamp: new Date().toISOString(), message: stepMessage, status: stepStatus || entry.status }); } -export function loadPersistedActivityLog() { - try { - if (!existsSync(CONFIG.ACTIVITY_LOG_PATH)) { - return { - status: 'missing', - path: CONFIG.ACTIVITY_LOG_PATH, - entriesLoaded: 0, - }; - } - const parsed = JSON.parse(readFileSync(CONFIG.ACTIVITY_LOG_PATH, 'utf-8')); - if (!Array.isArray(parsed)) { - return { - status: 'invalid', - path: CONFIG.ACTIVITY_LOG_PATH, - entriesLoaded: 0, - }; - } - activityLog.length = 0; - // Collapse restart-duplicated queue errors while keeping the newest unresolved copy. - activityLog.push(...dedupeQueueErrorEntries(parsed.slice(0, MAX_LOG_ENTRIES))); - return { - status: 'loaded', - path: CONFIG.ACTIVITY_LOG_PATH, - entriesLoaded: activityLog.length, - }; - } catch (e) { - return { - status: 'error', - path: CONFIG.ACTIVITY_LOG_PATH, - entriesLoaded: 0, - error: summarizeError(e), - }; - } -} - // ─── Download Pipeline ────────────────────────────────────────────────────── const downloadPipeline = new Map(); +// createdAt tracks total item age; stageStartedAt/stageChangedAt are reset on each transition. export function getPipeline() { return [...downloadPipeline.values()].sort((a, b) => b.createdAt - a.createdAt); @@ -239,7 +80,6 @@ export function advancePipeline(key, stage, extra = {}) { const item = downloadPipeline.get(key); if (!item) return; const now = Date.now(); - // createdAt tracks total age; stage timestamps reset on each transition. item.stage = stage; item.stageStartedAt = now; item.stageChangedAt = now; @@ -283,18 +123,7 @@ export const itunesPosterCache = new Map(); export const fastSearchCache = new Map(); export const fakeAlertsSeen = new Set(); -export let libraryCache = { - movies: [], - series: [], - artists: [], - albums: [], - lastRefresh: null, - serviceStates: { - series: { status: CONFIG.SONARR_API_KEY ? 'stale' : 'unconfigured', error: null }, - movies: { status: CONFIG.RADARR_API_KEY ? 'stale' : 'unconfigured', error: null }, - artists: { status: CONFIG.LIDARR_API_KEY ? 'stale' : 'unconfigured', error: null }, - }, -}; +export let libraryCache = { movies: [], series: [], artists: [] }; export function setLibraryCache(cache) { libraryCache = cache; @@ -314,13 +143,13 @@ export function clearFakeTorrentCheckInterval() { // ─── Watching / Polling State ────────────────────────────────────────────── export const watchedCommands = new Map(); +// Shutdown walks this registry instead of relying on each caller to remember its own timer. export const intervalIds = []; export function registerInterval(id) { intervalIds.push(id); } -// Shutdown walks this registry instead of relying on each caller to remember its own timer. export function clearAllIntervals() { for (const id of intervalIds) { clearInterval(id); @@ -330,107 +159,48 @@ export function clearAllIntervals() { // ─── Bandwidth Lifetime Tracking ──────────────────────────────────────────── -function coerceBwLifetimeShape(raw = {}) { - const legacyDl = Number(raw?.dl) || 0; - const legacyUl = Number(raw?.ul) || 0; +let bwLifetime = { baseline: { dl: 0, ul: 0 }, lastSession: { dl: 0, ul: 0 } }; + +function normalizeBwLifetime(value) { + if (value?.baseline && value?.lastSession) { + return { + baseline: { dl: value.baseline.dl || 0, ul: value.baseline.ul || 0 }, + lastSession: { dl: value.lastSession.dl || 0, ul: value.lastSession.ul || 0 }, + }; + } return { - baseline: { - dl: Number(raw?.baseline?.dl) || legacyDl, - ul: Number(raw?.baseline?.ul) || legacyUl, - }, - lastSession: { - dl: Number(raw?.lastSession?.dl) || 0, - ul: Number(raw?.lastSession?.ul) || 0, - }, + baseline: { dl: value?.dl || 0, ul: value?.ul || 0 }, + lastSession: { dl: 0, ul: 0 }, }; } -let bwLifetime = coerceBwLifetimeShape(); - -export function getBwLifetimeState() { - return { - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - }; +export function getBwLifetime() { + return normalizeBwLifetime(bwLifetime); } -export function noteQbSessionTotals(sessionDl, sessionUl) { - const normalizedDl = Number(sessionDl) || 0; - const normalizedUl = Number(sessionUl) || 0; - if (normalizedDl < bwLifetime.lastSession.dl || normalizedUl < bwLifetime.lastSession.ul) { - bwLifetime.baseline.dl += bwLifetime.lastSession.dl; - bwLifetime.baseline.ul += bwLifetime.lastSession.ul; - } - bwLifetime.lastSession.dl = normalizedDl; - bwLifetime.lastSession.ul = normalizedUl; - return getBwLifetimeState(); +export function setBwLifetime(next) { + bwLifetime = normalizeBwLifetime(next); } export function resetBwLifetime() { - bwLifetime = coerceBwLifetimeShape(); + bwLifetime = { baseline: { dl: 0, ul: 0 }, lastSession: { dl: 0, ul: 0 } }; } export function loadBwLifetime() { try { - if (!existsSync(CONFIG.BANDWIDTH_PATH)) { - return { - status: 'missing', - path: CONFIG.BANDWIDTH_PATH, - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - }; - } - const parsed = JSON.parse(readFileSync(CONFIG.BANDWIDTH_PATH, 'utf-8')); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - bwLifetime = coerceBwLifetimeShape(); - return { - status: 'invalid', - path: CONFIG.BANDWIDTH_PATH, - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - }; + if (existsSync(CONFIG.BANDWIDTH_PATH)) { + bwLifetime = normalizeBwLifetime(JSON.parse(readFileSync(CONFIG.BANDWIDTH_PATH, 'utf-8'))); } - bwLifetime = coerceBwLifetimeShape(parsed); - return { - status: 'loaded', - path: CONFIG.BANDWIDTH_PATH, - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - }; } catch (e) { - return { - status: 'error', - path: CONFIG.BANDWIDTH_PATH, - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - error: summarizeError(e), - }; + console.error('Failed to load bandwidth lifetime:', e.message); } } export function saveBwLifetime() { try { writeFileSync(CONFIG.BANDWIDTH_PATH, JSON.stringify(bwLifetime)); - if (persistenceHealth.bandwidthSaveFailed) { - persistenceHealth.bandwidthSaveFailed = false; - logServerEvent('info', 'state.bandwidth_persist.recovered', { - path: CONFIG.BANDWIDTH_PATH, - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - }); - } - return true; } catch (e) { - if (!persistenceHealth.bandwidthSaveFailed) { - logServerEvent('error', 'state.bandwidth_persist.failed', { - path: CONFIG.BANDWIDTH_PATH, - baseline: { ...bwLifetime.baseline }, - lastSession: { ...bwLifetime.lastSession }, - error: summarizeError(e), - }); - } - persistenceHealth.bandwidthSaveFailed = true; - return false; + console.error('Failed to save bandwidth lifetime:', e.message); } } @@ -438,24 +208,9 @@ export function saveBwLifetime() { export function persistActivityLog() { try { - writeFileSync(CONFIG.ACTIVITY_LOG_PATH, JSON.stringify(activityLog.slice(0, MAX_PERSISTED_LOG_ENTRIES))); - if (persistenceHealth.activitySaveFailed) { - persistenceHealth.activitySaveFailed = false; - logServerEvent('info', 'state.activity_log_persist.recovered', { - path: CONFIG.ACTIVITY_LOG_PATH, - persistedEntries: Math.min(activityLog.length, MAX_PERSISTED_LOG_ENTRIES), - }); - } - return true; + // Keep a deeper in-memory tail for the live UI, but cap the persisted file on disk. + writeFileSync(CONFIG.ACTIVITY_LOG_PATH, JSON.stringify(activityLog.slice(0, 100))); } catch (e) { - if (!persistenceHealth.activitySaveFailed) { - logServerEvent('error', 'state.activity_log_persist.failed', { - path: CONFIG.ACTIVITY_LOG_PATH, - persistedEntries: Math.min(activityLog.length, MAX_PERSISTED_LOG_ENTRIES), - error: summarizeError(e), - }); - } - persistenceHealth.activitySaveFailed = true; - return false; + console.error('Failed to persist activity log:', e.message); } } diff --git a/backend/src/utils.js b/backend/src/utils.js index e2b4427..c98200b 100644 --- a/backend/src/utils.js +++ b/backend/src/utils.js @@ -1,66 +1,19 @@ import { TIMING, CONFIG } from './config.js'; -export function hasText(value) { - return typeof value === 'string' && value.trim() !== ''; -} - -export async function fetchWithTimeout(url, timeoutMs = 5000, headers = {}) { +export async function fetchWithTimeout(url, timeoutMs = 5000, headers = {}, { parseJson = true } = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const resp = await fetch(url, { signal: controller.signal, headers }); if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${url}`); - return await resp.json(); - } finally { - clearTimeout(timer); - } -} - -export async function fetchTextWithTimeout(url, timeoutMs = 5000, headers = {}) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const resp = await fetch(url, { signal: controller.signal, headers }); - if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${url}`); - return await resp.text(); + return parseJson ? await resp.json() : await resp.text(); } finally { clearTimeout(timer); } } export async function arrFetch(url, apiKey, timeoutMs = 5000) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const resp = await fetch(url, { - signal: controller.signal, - headers: { 'X-Api-Key': apiKey }, - }); - if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${url}`); - return await resp.json(); - } finally { - clearTimeout(timer); - } -} - -export function arrPost(url, apiKey, body) { - return fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey }, - body: JSON.stringify(body), - }); -} - -export function arrPut(url, apiKey, body) { - return fetch(url, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey }, - body: JSON.stringify(body), - }); -} - -export function arrDelete(url, apiKey) { - return fetch(url, { method: 'DELETE', headers: { 'X-Api-Key': apiKey } }); + return fetchWithTimeout(url, timeoutMs, { 'X-Api-Key': apiKey }); } export function pickImageUrl(images, coverType) { @@ -100,29 +53,13 @@ export function parseArrError(text, status) { } export function normalizeForMatch(s) { - return (s || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + return (s || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9\s]/g, '') - .replace(/\s+/g, ' ').trim(); -} - -export function normalizeServiceUrl(url) { - try { - return new URL(url).origin; - } catch { - return url; - } -} - -export function hasQbittorrentCredentials() { - return hasText(CONFIG.QBITTORRENT_USER) && hasText(CONFIG.QBITTORRENT_PASS); -} - -export async function probeQbittorrentVersion(timeoutMs = 5000) { - if (!hasText(CONFIG.QBITTORRENT_HOST)) { - throw new Error('qBittorrent host is not configured'); - } - const version = await fetchTextWithTimeout(`${CONFIG.QBITTORRENT_HOST}/api/v2/app/version`, timeoutMs); - return version.trim(); + .replace(/\s+/g, ' ') + .trim(); } // ─── qBittorrent session cache ────────────────────────────────────────────── @@ -135,34 +72,29 @@ export async function qbittorrentLogin(forceRefresh = false) { return { qbHost: QB_CACHE.qbHost, cookie: QB_CACHE.cookie }; } if (QB_CACHE.inflight) return QB_CACHE.inflight; - const qbHost = CONFIG.QBITTORRENT_HOST?.trim(); - const qbUser = CONFIG.QBITTORRENT_USER?.trim(); - const qbPass = CONFIG.QBITTORRENT_PASS?.trim(); - if (!hasText(qbHost)) { - throw new Error('qBittorrent host is not configured. Set QBITTORRENT_HOST.'); - } - if (!hasText(qbUser) || !hasText(qbPass)) { - throw new Error('qBittorrent credentials are not configured. Set QBITTORRENT_USER and QBITTORRENT_PASS.'); - } + const qbHost = CONFIG.QBITTORRENT_HOST; + const qbUser = CONFIG.QBITTORRENT_USER; + const qbPass = CONFIG.QBITTORRENT_PASS; QB_CACHE.inflight = (async () => { const loginResponse = await fetch(qbHost + '/api/v2/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Referer': `${qbHost}/`, - 'Origin': qbHost, + Referer: qbHost, + Origin: qbHost, }, - body: 'username=' + encodeURIComponent(qbUser) + '&password=' + encodeURIComponent(qbPass) + body: 'username=' + encodeURIComponent(qbUser) + '&password=' + encodeURIComponent(qbPass), }); - const loginBody = await loginResponse.text(); - const loginText = loginBody.trim(); if (!loginResponse.ok) { throw new Error('qBittorrent returned HTTP ' + loginResponse.status + ' - is ' + qbHost + ' reachable?'); } - if (loginResponse.status === 204 ? loginText !== '' : loginText !== 'Ok.') { - throw new Error('Authentication rejected by qBittorrent (user: ' + qbUser + '). Check QBITTORRENT_USER and QBITTORRENT_PASS.'); + const loginBody = await loginResponse.text(); + if (loginBody.trim() !== 'Ok.') { + throw new Error( + 'Authentication rejected by qBittorrent (user: ' + qbUser + '). Check QBITTORRENT_USER and QBITTORRENT_PASS.', + ); } - const cookie = loginResponse.headers.get('set-cookie')?.split(';')?.[0]?.trim(); + const cookie = loginResponse.headers.get('set-cookie'); if (!cookie) { throw new Error('qBittorrent login succeeded but no session cookie returned'); } @@ -182,49 +114,17 @@ export function invalidateQbSession() { QB_CACHE.expiresAt = 0; } -async function qbRequest(path, options = {}, forceRefresh = false) { - const { timeoutMs = 5000, headers = {}, ...fetchOptions } = options; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const { qbHost, cookie } = await qbittorrentLogin(forceRefresh); - const resp = await fetch(qbHost + path, { - ...fetchOptions, - signal: controller.signal, - headers: { - Cookie: cookie, - Referer: `${qbHost}/`, - ...headers, - }, - }); - if ((resp.status === 401 || resp.status === 403) && !forceRefresh) { - invalidateQbSession(); - return qbRequest(path, options, true); - } - if (!resp.ok) { - throw new Error(`qBittorrent ${path} HTTP ${resp.status}`); - } - return resp; - } finally { - clearTimeout(timer); - } -} - -export async function qbFetchJson(path, options = {}) { - const resp = await qbRequest(path, options); - return resp.json(); -} - -export async function qbFetchText(path, options = {}) { - const resp = await qbRequest(path, options); - return resp.text(); -} - export async function qbAction(hash, action, extra) { + const { qbHost, cookie } = await qbittorrentLogin(); const body = 'hashes=' + encodeURIComponent(hash) + (extra ? '&' + extra : ''); - await qbRequest('/api/v2/torrents/' + action, { + const r = await fetch(qbHost + '/api/v2/torrents/' + action, { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Cookie: cookie, + Referer: qbHost, + }, body, }); + if (!r.ok) throw new Error('qBittorrent ' + action + ' HTTP ' + r.status); } diff --git a/backend/test/routes.test.js b/backend/test/routes.test.js new file mode 100644 index 0000000..1a10b14 --- /dev/null +++ b/backend/test/routes.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import mountRoutes from '../src/routes/index.js'; +import { errorHandler } from '../src/middleware.js'; +import { clearAllIntervals } from '../src/state.js'; + +function createTestServer() { + const app = express(); + app.use(express.json()); + app.use('/api', mountRoutes); + app.use(errorHandler); + const server = app.listen(0); + const { port } = server.address(); + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +test('health route returns an ok status envelope', async t => { + const { server, baseUrl } = createTestServer(); + t.after(() => { + server.close(); + clearAllIntervals(); + }); + + const response = await fetch(`${baseUrl}/api/health`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, 'ok'); + assert.match(body.timestamp, /^\d{4}-\d{2}-\d{2}T/); +}); + +test('grab route does not reject release push requests as missing guid', async t => { + const { server, baseUrl } = createTestServer(); + t.after(() => { + server.close(); + clearAllIntervals(); + }); + + const response = await fetch(`${baseUrl}/api/grab`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + service: 'radarr', + downloadUrl: 'https://example.invalid/release.torrent', + title: 'Example', + }), + }); + + const body = await response.json(); + assert.notEqual(body.error, 'Missing guid'); + assert.notEqual(body.error, 'Missing guid or downloadUrl'); +}); diff --git a/backend/test/utils.test.js b/backend/test/utils.test.js index b7553e7..6ec816f 100644 --- a/backend/test/utils.test.js +++ b/backend/test/utils.test.js @@ -1,28 +1,29 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { pickArrImageUrl, pickImageUrl } from '../src/utils.js'; +import { pickImageUrl, pickArrImageUrl } from '../src/utils.js'; test('pickImageUrl accepts protocol-relative remote URLs', () => { - const images = [{ coverType: 'poster', remoteUrl: '//image.tmdb.org/t/p/original/poster.jpg' }]; - assert.equal(pickImageUrl(images, 'poster'), 'https://image.tmdb.org/t/p/original/poster.jpg'); + const images = [{ coverType: 'poster', remoteUrl: '//images.example.com/movie/poster.jpg' }]; + assert.equal(pickImageUrl(images, 'poster'), 'https://images.example.com/movie/poster.jpg'); }); -test('pickArrImageUrl accepts protocol-relative remote URLs', () => { - const images = [{ coverType: 'poster', remoteUrl: '//image.tmdb.org/t/p/original/poster.jpg' }]; - assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), 'https://image.tmdb.org/t/p/original/poster.jpg'); +test('pickImageUrl handles remote absolute URLs', () => { + const images = [{ coverType: 'poster', remoteUrl: 'https://images.example.com/movie/poster.jpg' }]; + assert.equal(pickImageUrl(images, 'poster'), 'https://images.example.com/movie/poster.jpg'); }); -test('pickArrImageUrl keeps absolute remote URLs instead of proxying them as Arr paths', () => { - const images = [{ coverType: 'poster', url: 'https://image.tmdb.org/t/p/original/poster.jpg' }]; - assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), 'https://image.tmdb.org/t/p/original/poster.jpg'); +test('pickArrImageUrl normalizes protocol-relative remote URLs', () => { + const images = [{ coverType: 'poster', remoteUrl: '//images.example.com/movie/poster.jpg' }]; + assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), 'https://images.example.com/movie/poster.jpg'); }); -test('pickArrImageUrl builds valid proxy URLs for relative Arr image paths', () => { - const images = [{ coverType: 'poster', url: 'MediaCover/40/poster.jpg' }]; - assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), '/api/arr-image/radarr/MediaCover/40/poster.jpg'); +test('pickArrImageUrl builds valid arr-image urls for relative paths without leading slash', () => { + const images = [{ coverType: 'poster', url: 'MediaCover/1/poster.jpg' }]; + assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), '/api/arr-image/radarr/MediaCover/1/poster.jpg'); }); -test('pickArrImageUrl does not double slash leading Arr image paths', () => { - const images = [{ coverType: 'poster', url: '/MediaCover/40/poster.jpg' }]; - assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), '/api/arr-image/radarr/MediaCover/40/poster.jpg'); +test('pickArrImageUrl builds valid arr-image urls for leading slash paths', () => { + const images = [{ coverType: 'poster', url: '/MediaCover/1/poster.jpg' }]; + assert.equal(pickArrImageUrl(images, 'poster', 'radarr'), '/api/arr-image/radarr/MediaCover/1/poster.jpg'); }); + diff --git a/docker-compose.yml b/docker-compose.yml index 8d66474..af484f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,15 +3,14 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: vibarr-backend + container_name: arr-dashboard-backend user: "0:0" environment: - NODE_ENV=production - PORT=3000 - - INSTALLER_ENABLED=${INSTALLER_ENABLED:-true} - QBITTORRENT_HOST=${QBITTORRENT_HOST:-http://qbittorrent:8080} - - QBITTORRENT_USER=${QBITTORRENT_USER:-} - - QBITTORRENT_PASS=${QBITTORRENT_PASS:-} + - QBITTORRENT_USER=${QBITTORRENT_USER:-admin} + - QBITTORRENT_PASS=${QBITTORRENT_PASS:-adminadmin} - RADARR_HOST=${RADARR_HOST:-http://radarr:7878} - RADARR_API_KEY=${RADARR_API_KEY} - SONARR_HOST=${SONARR_HOST:-http://sonarr:8989} @@ -23,12 +22,10 @@ services: - SLSKD_API_KEY=${SLSKD_API_KEY} - PROWLARR_HOST=${PROWLARR_HOST:-http://prowlarr:9696} - PROWLARR_API_KEY=${PROWLARR_API_KEY} - - INSTALLER_STATE_PATH=${INSTALLER_STATE_PATH:-/app/installer-state.json} volumes: - - /var/run/docker.sock:/var/run/docker.sock + - /var/run/docker.sock:/var/run/docker.sock:ro - ./backend/activity-log.json:/app/activity-log.json - ./backend/bandwidth-lifetime.json:/app/bandwidth-lifetime.json - - ${INSTALLER_STATE_HOST_PATH:-./backend/installer-state.json}:${INSTALLER_STATE_PATH:-/app/installer-state.json} networks: - arr-network restart: unless-stopped @@ -43,7 +40,7 @@ services: build: context: ./frontend dockerfile: Dockerfile - container_name: vibarr-frontend + container_name: arr-dashboard-frontend ports: - "${DASHBOARD_PORT:-8888}:80" depends_on: @@ -60,4 +57,4 @@ services: networks: arr-network: - name: ${ARR_NETWORK_NAME:-vibarr-network} + name: ${ARR_NETWORK_NAME:-arr-dashboard-network} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 4e25e2e..bd6903a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -4,17 +4,7 @@ USER root WORKDIR /app COPY package*.json ./ -RUN set -eu; \ - attempt=1; \ - while true; do \ - npm ci && break; \ - if [ "$attempt" -ge 5 ]; then \ - exit 1; \ - fi; \ - attempt=$((attempt + 1)); \ - npm cache clean --force; \ - sleep 5; \ - done +RUN npm install COPY . . RUN npm run build diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..fd417bd --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,37 @@ +import js from '@eslint/js'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; + +export default [ + js.configs.recommended, + { + ignores: ['dist/', 'node_modules/'], + }, + { + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + settings: { + react: { + version: 'detect', + }, + }, + rules: { + 'no-unused-vars': 'warn', + 'no-console': 'off', + 'no-undef': 'off', + eqeqeq: 'warn', + curly: 'off', + }, + }, +]; diff --git a/frontend/index.html b/frontend/index.html index 23090a3..0a6789d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,12 +1,15 @@ - +
-{cfg.label}
{new Date(entry.timestamp).toLocaleString()}
{step.message}
@@ -126,43 +149,70 @@ function DetailModal({ entry, onClose, slskdDownloads, onDeleteSlskd, onRetry }) })}{d.errorMessage}
- )} -{d.errorMessage}
+ )} +{sm.title}
} - {sm.messages?.map((msg, j) => ( -{msg}
+ )} + {hasStatusMsgs && + d.statusMessages.map((sm, i) => ( +{sm.title}
+ )} + {sm.messages?.map((msg, j) => ( ++ {msg} +
+ ))} +Soularr will search Soulseek. Downloads appear here once they begin.
+ + cloud_queue + ++ Soularr will search Soulseek. Downloads appear here once they begin. +
{dl.artistName} — {dl.albumName}
++ {dl.artistName} — {dl.albumName} +
{file.name}
{entry.message}
++ {entry.message} +
Loading vibarr…
-- Setup -
-- {showInstaller - ? 'On a fresh VM, the dashboard can now provision qBittorrent, Radarr, Sonarr, Lidarr, Prowlarr, and SLSKD. Pick the host paths and a few defaults, then the backend will create and wire the stack for you.' - : setupFlow.manualConfigOnly - ? 'This deployment is still configured through backend `.env` values. Update the relevant host or API key entries there, restart the API, then refresh status here.' - : setupFlow.phaseInProgress || awaitingBackendRestart - ? 'The installer finished enough work to restart the backend, but this screen should stay active until the backend reports a ready phase or readiness flag.' - : setupRequired - ? 'The backend is running, but no library service is ready yet. Configure at least one of Radarr, Sonarr, or Lidarr in the backend environment, then refresh status.' - : hasIssues - ? 'The dashboard can stay up while a subset of services is unavailable, but unavailable integrations should not silently pretend the library is empty anymore.' - : 'All configured services reported healthy on the last status check.'} -
-- This provisions the core containers, sets root folders, creates qBittorrent download clients inside the Arr apps, - stores the generated API keys for the dashboard, and restarts the backend onto the new stack. -
-- This stack is still `.env`-driven. Update the backend service hosts, credentials, and API keys there, restart the API process, then use Refresh Status here to confirm readiness. -
-- {meta.detail} -
-Active Searches
- - {pipeline.length} - - - {[ - pipelineStages.searching.length > 0 && pipelineStages.searching.length + ' searching', - pipelineStages.downloading.length > 0 && pipelineStages.downloading.length + ' downloading', - pipelineStages.stuck.length > 0 && pipelineStages.stuck.length + ' stuck', - ].filter(Boolean).join(' · ')} - -Click card to expand log
-Recent Searches
- - {pendingSearches.length} - -Soulseek
-qBittorrent unavailable
-{torrentError}
-- {showStartingSearchMessage ? 'Starting search…' : 'All clear'} -
-- {showStartingSearchMessage ? 'Watch for the new search to appear in active items' : 'No active transfers'} -
-{title}
} -{details.message}
- {meta.length > 0 && ( -{meta.join(' · ')}
- )} - {details.warnings.length > 0 && ( -Warnings: {details.warnings.join(' · ')}
- )} -{error}
- ) : sorted.length === 0 ? ( -- {exactMatchOnly && releases.filter(r => r.rejected).length > 0 - ? `No exact matches — toggle filter to see ${releases.length} rejected release${releases.length !== 1 ? 's' : ''}` - : (qualityFilters.length > 0 || minSeeders > 0) - ? `No results match current filters — ${releases.length} total available` - : 'No releases found from indexers'} -
- {exactMatchOnly && releases.filter(r => r.rejected).length > 0 && (() => { - const rejected = releases.filter(r => r.rejected); - const counts = {}; - for (const r of rejected) { - for (const rej of (r.rejections || [])) { - const cat = rej.includes('alias') ? 'Title alias conflict' - : rej.includes('seeders') ? 'No seeders' - : rej.includes('not wanted in profile') ? 'Quality profile' - : rej.includes('Unknown') ? 'Unrecognized' - : rej.includes('Wrong season') ? 'Wrong season' - : rej.includes('Existing file') ? 'Already downloaded' - : rej.includes('Episode wasn') ? 'Not monitored' - : 'Other'; - counts[cat] = (counts[cat] || 0) + 1; - } - } - const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 5); - if (entries.length === 0) return null; - return ( -Delete this series?
-No episodes found
- ) : ( -All content downloaded
-{seasonNums.filter(sn => sn > 0).length} season(s) · {seasonNums.reduce((a, sn) => a + (episodes[sn]?.length || 0), 0)} episodes
-Delete this artist?
-No albums found
- ) : ( -{a.title}
-- {a.releaseDate ? new Date(a.releaseDate).getFullYear() : 'Unknown'} - {' · '} - 0 ? 'text-accent-green' : 'text-accent-orange'}> - {a.trackFileCount}/{a.trackCount} tracks - - {a.sizeOnDisk > 0 && ` · ${formatBytes(a.sizeOnDisk)}`} -
-No additional releases found from iTunes/MusicBrainz.
- ) : ( - <> -No files found on disk
- ) : ( -All albums downloaded
-{downloadedAlbums.length} album{downloadedAlbums.length !== 1 ? 's' : ''} · click trash icon to remove files
-{item.overview}
} - {item.genres?.length > 0 && ( -{profileError || 'Failed to load profiles'}
- ) : ( - <> -This title is already added to your library. Closing this panel keeps it monitored so you can come back later.
-{subtitle}
} -{item.overview}
} -{movie.hasFile ? `Downloaded · ${movie.quality || 'Unknown quality'}` : 'Missing from disk'}
- {movie.sizeOnDisk > 0 &&{formatBytes(movie.sizeOnDisk)}
} - {fileInfo && ( -- 📁 {fileInfo.path} -
- )} -Delete this movie?
-
- Unavailable right now: {unavailableServices.join(', ')}.
- {' '}This stack still reads manual service config from backend `.env` values.
-
{activeLibraryIssue.error}
- )} -- {loading ? 'Searching...' : isMissingFilter - ? `${totalLibrary} item${totalLibrary !== 1 ? 's' : ''} need attention` - : `${totalLibrary} result${totalLibrary !== 1 ? 's' : ''}`} -
- )} -Loading library…
-- This view is ready, but the backend does not have library endpoints configured yet. Once those services are connected, browsing and missing-item triage will populate here automatically. -
-Configure
-Add the Arr URLs and API keys in backend `.env`.
-Restart
-Restart the API so the new service bindings are picked up.
-Refresh
-Return here to browse the live catalog and missing items.
-- {isLibraryEmptyWelcome - ? 'Your library is empty.' - : libraryError || isServiceIssueStatus(activeLibraryIssue?.status) - ? 'Library data is temporarily unavailable for this view.' - : isMissingFilter - ? 'Nothing missing — library is complete!' - : `No media found${query ? ` for "${query}"` : ''}`} -
- {libraryError || isServiceIssueStatus(activeLibraryIssue?.status) ? ( -
- Existing results stay visible when possible, but this request did not complete cleanly.
-
- Get started by searching and adding something to your library.
-
Try a different search, or switch to
- Add mode pulls live results from Radarr, Sonarr, or Lidarr. Until at least one of those services is configured, search stays disabled to avoid sending you into a dead-end flow. -
-Endpoint
-Point the backend at the Arr service URL.
-Auth
-Provide the matching API key in backend `.env`.
-Return
-Refresh this view to start searching the live catalog.
-No results for "{query}"
-- Search by title, artist, or release name. Existing library matches stay visible so you can avoid duplicates and jump straight into the next acquisition. -
-Search
-Use the field above to query the live catalog.
-Review
-Check quality, year, and library status before adding.
-Queue
-Send the selection into the pipeline and track it from Downloads.
-{lookupResults.length} result{lookupResults.length !== 1 ? 's' : ''}
- - Source: {addType === 'movie' ? 'Radarr' : addType === 'series' ? 'Sonarr' : 'Lidarr'} - -{item.stuckReason}
++ {item.stuckReason} +
{actionMsg && ( -{actionMsg.text}
++ {actionMsg.text} +
)}{actionErr}
- )} -